design: QA 4라운드 회신 1차 반영 — 즉시 처리 가능한 정정 20건
사용자 회신(pre-implementation-qa-round4-response.md) 중 판단이 명확한 항목을 base/에 반영. 굵직한 것만: - LP-1: quad의 `Connected`는 "계산된 속성"이 아니라 그냥 RBXScriptConnection의 네이티브 필드다 — rbvm 프록시 사정을 잘못 옮겨온 서술이었음. 실제 판정은 "gcconn이 없음" / "있는데 Connected==false" 두 상태뿐. - D-56: `bindLifetime`의 첫 인자가 Instance가 아닐 수 있다(Slot-in-Slot의 ownerKey) — 백엔드가 반드시 핸들링해야 하는 요구사항으로 신설. gcconn 트릭이 안 통하므로 세 번째 판정 분기가 필요하다는 것까지 명시. - SL-75/D-60: 언마운트 시 `slot.Offset = nil`은 포탈을 깨뜨림(이미 구독 중인 다운스트림이 영구히 끊김) — stale하게 두는 게 맞고, 마운트 전 기본값도 nil이 아니라 0. - SL-74: `SetAndDispose`는 `source:SetAndDispose(value)` 콜론 메서드로 확정. `Apply` 오버라이딩은 Source→State 단방향 때문에 타입이 안 성립. - E-11: leaf 바인딩된 Effect엔 `:Unsubscribe()`가 아예 안 먹는 것으로 Observer와 통일. 옛 "(3) 이후 leaf가 죽어도 중복 호출 안 됨"은 이중 바인딩 게이트상 성립할 수 없는 문장이라 삭제. - AT-20: 생존 이름 최적화는 "부품이 늘어나서 안 하는" 게 아니라 값 비교가 필요해 **원리적으로 불가능**하다(State 계약상 `:Get()` 비교 금지). - UI-8: `mapTweenValue` 로컬 헬퍼를 `Tween<T>:Map(fn)` 공개 메소드로 승격. 부수로 D-3(retract가 깊은 인덱스부터인 이유)/D-10(두 패스를 명시하는 진짜 이유는 순서를 못 믿어서가 아니라 이식성)/LH-8(자기 아래 vs 자기 위)을 코드 예시로 풀어 썼고, TW-16으로 HUMAN_TODO에 initValue 항목을 신설했다. Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
e80aa11c4f
commit
41b96e6917
15 changed files with 470 additions and 73 deletions
|
|
@ -239,9 +239,14 @@ end
|
|||
그룹 `Attribute` 값은 여러 곳에서 쓸 수 있어야 한다.
|
||||
- **확정된 방향**: 위치별 claim 레지스트리를 하나 더 둬서 **같은 그룹
|
||||
객체가 같은 위치 집합을 이중 점유하는 것만** 잡는다.
|
||||
- **미정 — 구현 전에 정할 것**: 그 claim의 키를 무엇으로 할지
|
||||
(`(inst, groupValue) → k`인지 `groupKey` 단위인지), 그리고 기존
|
||||
`nameClaims`와 어떻게 공존하는지. `question.md`에 올려둠.
|
||||
- **[해소, 2026-08-20 구현 전 QA 4라운드 `AT-11`] 이름은 `groupClaimKeys`로
|
||||
확정**(사용자: *"groupClaimKeys 정도로 확정. 더 나은 답이 있다면 적어주길
|
||||
바람. 다만 충분하다고 생각하는 이름임."*). `nameClaims`(이름 → 그 이름을
|
||||
잡은 키 객체)와 나란히 놓이는 두 번째 레지스트리이고, 이름 자체가
|
||||
"그룹이 claim한 키들"을 가리켜 역할이 드러남.
|
||||
- **⚠️ 키 설계는 여전히 미정** — 확정된 건 이름뿐이고, `(inst, groupValue) → k`
|
||||
인지 `groupKey` 단위인지, 그리고 `nameClaims`와 어떤 순서로 확인하는지는
|
||||
M10 구현 전에 정한다. `question.md` 참고.
|
||||
- **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게
|
||||
**정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`)
|
||||
— 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹
|
||||
|
|
@ -298,9 +303,16 @@ Store 필드 여러 개를 각각 `[AttributeKey<<T>> "name"] = store.name`으
|
|||
|
||||
```
|
||||
Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 개 받음
|
||||
-- [명시 추가, 2026-08-20 구현 전 QA 4라운드] plain 테이블의 값은 raw T뿐 아니라
|
||||
-- State<T>/Source<T>도 그대로 됨 — {count = 3, label = someSource, live = state}
|
||||
-- 새 배선이 아니라, 이 값들이 결국 단일 키 경로로 위임돼 StoreBind가 언랩하기 때문
|
||||
Attribute.Merged(a, b, ...): Attribute -- 합성, 이름이 겹치면 error
|
||||
Attribute.Overridden(a, b, ...): Attribute -- 합성, 이름이 겹치면 조용히 뒤가 이김
|
||||
attr:NameMap(): {[string]: Source<any>} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것)
|
||||
-- [명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 자신의 이름 겹침 정책:
|
||||
-- Attribute(a, b)에서 a와 b가 같은 이름을 가지면 **뒤에 온 인자가 이긴다**
|
||||
-- (= Overridden과 같은 정책, error 아님). Merged의 "겹치면 error"는 그 함수만의
|
||||
-- 정책이고 생성자엔 적용되지 않음 — 겹침을 막고 싶으면 Attribute.Merged를 쓸 것
|
||||
Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 둬도 각자 자기 키만 반영(Tag와 동일)
|
||||
```
|
||||
|
||||
|
|
@ -324,6 +336,15 @@ Overridden 을 제공하면 될것 같음. 전자는 에러를 내주고, 후자
|
|||
시점 1회 체크라 싸다). 이 문서의 다른 결정들(이름 claim 충돌 = 즉시
|
||||
error)과 결이 같음.
|
||||
- `Attribute.Overridden(a, b, ...)` — 겹치면 **조용히 뒤가 이김**.
|
||||
- **[명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 `Attribute(a, b, ...)`
|
||||
자신도 "뒤가 이김"이다** — 사용자 확인 요청(*"그리고 뒤에 오는기 이기는 것
|
||||
또한, 잘 명시되어있나 봐야함"*)으로 점검한 결과, 위 API 목록이 `Merged`/
|
||||
`Overridden`의 정책만 적어두고 **생성자 자체의 겹침 정책은 어디에도 안
|
||||
적혀 있었다**. `Frame { Tag("a"), Tag("a") }`처럼 배열에 여러 개 나란히
|
||||
놓는 것과 달리 생성자는 인자들을 **하나의 이름 맵으로 평탄화**하므로 겹침
|
||||
판정이 반드시 필요하고, 배열 순서 규칙(`base/modifier-plan.md`의 "Merge
|
||||
우선순위" — 나중 것이 우선)과 결을 맞춰 **뒤가 이기는 것**으로 명시한다.
|
||||
겹침을 실수로 보고 싶으면 `Attribute.Merged`를 명시적으로 쓰면 된다.
|
||||
- **⚠️ 이 이름 쌍의 의미가 코퍼스 전체에서 재정렬된다** — 지금까지
|
||||
`Merged`(=무손실 합집합, `Tag`)와 `Overridden`(=필드 단위 덮어쓰기,
|
||||
`Modifier`)은 **연산의 종류**를 가르는 이름이었는데, `Attribute`에선
|
||||
|
|
@ -400,12 +421,22 @@ end
|
|||
그 이름의 `StoreBind` 구독 해제+재구독, 그리고 재구독의 "등록 즉시 1회
|
||||
실행"이 같은 값으로 `setAttribute`를 한 번 더 쏘는 것뿐. 이 문서가 이미
|
||||
"값 비교(`:Get()`으로 old/new 비교)는 안 함"을 확정해뒀으므로(아래 항목)
|
||||
결이 같고, `setAttribute`는 같은 값 재기록이 관측상 무해함. **체인이
|
||||
그룹 전용이 된 지금은 이론상 "생존 이름은 그냥 다시 `Dispatch.process`만
|
||||
불러 하강 diff에 맡기는" 최적화도 가능하지만**(다른 소유자를 건드릴
|
||||
위험이 없어졌으므로), 그러려면 옛 이름 집합을 `(inst,위치)`별로 또
|
||||
들고 있어야 해서 부품이 늘어남 — **기본은 균일 철거 유지**, 최적화는
|
||||
실제로 비용이 문제될 때 재검토.
|
||||
결이 같고, `setAttribute`는 같은 값 재기록이 관측상 무해함. **[전면 정정, 2026-08-20 구현 전 QA 4라운드 `AT-20`] "체인이 그룹
|
||||
전용이 된 지금은 이론상 최적화도 가능하지만 부품이 늘어나서 안 한다"는
|
||||
옛 서술은 틀렸다 — 애초에 최적화가 성립하지 않는다.** 사용자 판정:
|
||||
*"생존 이름에 대해서 최적화 불가함. 실 value 자체가 바뀌고 같은 값인지
|
||||
비교를 해야하는데, 그러면 값을 진짜 까봐야 하고, 이전 값을 알아야하기
|
||||
때문. 이름 목록의 변경으로 최적화가 되는 요소가 아님."*
|
||||
- **`Tag`와 결정적으로 다른 지점**: `Tag`는 자원이 **이름 집합**이라
|
||||
"이름이 그대로면 할 일 없음"이 성립하지만, `Attribute`는 이름이 그대로여도
|
||||
**그 이름에 붙은 값이 바뀌었을 수 있다** — 이름 목록 비교만으로는 아무것도
|
||||
못 건너뛴다.
|
||||
- **값을 비교하려면 `:Get()`을 해야 하고 그건 금지**(위 "값 비교(`:Get()`으로
|
||||
old/new 비교)는 안 함" 항목) — State 계약상 캐시 비교를 하면 안 되고,
|
||||
비교하려면 이전 값까지 따로 들고 있어야 한다. 즉 "부품이 늘어난다"가
|
||||
아니라 **설계 원칙 위반이라 애초에 못 한다**.
|
||||
- 그래서 **균일 철거→재등록이 유일한 경로**이고, 비용(재구독 + 같은 값
|
||||
`setAttribute` 1회)은 수용한다.
|
||||
- **그룹이 이름을 아예 놓는 경우도 같은 코드로 자연히 처리됨** — 클로저가
|
||||
자기 키 전부를 걷어내고, 새 `process`가 새 이름 집합만 등록하므로 사라진
|
||||
이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없음.
|
||||
|
|
|
|||
|
|
@ -139,6 +139,16 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
|
|||
충분하다고 판단됐기 때문(처음 제안됐던 "`HasBlocked`"는 이 논의
|
||||
과정에서 자연스럽게 불필요해짐 — `qa-request/pre-implementation-qa-round2.md`
|
||||
"RC-1" 절에 논의 경위 기록).
|
||||
**[보강, 2026-08-20 구현 전 QA 4라운드 `BK-9`] "영원히 안 만든다"로 못박은
|
||||
건 아니다 — 지금 사용 케이스가 없을 뿐인 백로그다.** 사용자 판정: *"있는게
|
||||
어렵지 않다고 보긴 하나, 사용 케이스가 없었을 뿐임 … 나중에 사용 필요 요구가
|
||||
나오면 그 때 구현하여도 될 요소로 보임. 아마 HasBlockedState 로 하나가
|
||||
신설될 가능성이 존재하지 않는다고 못 박기는 이름."* 즉 (a) 구현 난이도가
|
||||
낮고, (b) 신설된다면 이름은 `HasBlocked`가 아니라 **`HasBlockedState`**
|
||||
쪽이 될 가능성이 높으며(그 Blocker에 걸린 gated state 중 대기 중인 게
|
||||
있는가 = "블록된 state가 있는가"라 이름이 더 정직함), (c) 실제 요구가
|
||||
나오기 전엔 안 만든다 — `conventions.md`의 "드문 오용이나 가상의 미래
|
||||
요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙 그대로.
|
||||
|
||||
## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,35 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
|
|||
"Dispatch 체인" 절 참고) — 클로저는 **오직 자기 자신의 직접
|
||||
자원**(Observer 구독 등)만 정리하면 됨.
|
||||
|
||||
> **[용어 풀이, 2026-08-20 구현 전 QA 4라운드 `D-3`] "깊은 인덱스"가 뭘
|
||||
> 뜻하는가 — 그리고 설치와 철거는 실제로 반대 방향이 맞다.**
|
||||
> "깊다"는 **인덱스 숫자가 크다**는 뜻이다(트리 깊이가 아니라 **같은
|
||||
> `(inst,k)` 체인 안에서의 재귀 깊이**). `State<State<Tag>>`를 예로 들면:
|
||||
>
|
||||
> ```
|
||||
> (inst, k) 체인
|
||||
> index 1 : StoreBind ← 바깥 State를 구독. "얕음"
|
||||
> index 2 : StoreBind ← 안쪽 State를 구독(바깥이 재귀로 만든 것)
|
||||
> index 3 : TagHandler ← 최종 Tag를 실제로 반영. "깊음"
|
||||
> ```
|
||||
>
|
||||
> - **설치(`Dispatch.process`)는 1 → 2 → 3 순** — 사용자가 짚은 그대로.
|
||||
> 각 레벨이 값을 한 겹 벗겨 `index + 1`로 재귀하므로 인덱스가 커지는
|
||||
> 방향으로 진행한다.
|
||||
> - **철거(`Dispatch.retractFrom`)는 그 반대인 3 → 2 → 1 순이 맞다** —
|
||||
> 아래 "Dispatch 체인" 절의 의사코드가 `for i = #list, index, -1`로
|
||||
> **꼬리부터 역순**으로 돈다. 즉 사용자가 되물은 *"달라질 때 5, 4, 3, 2
|
||||
> … 순이 되는건 아니지?"*의 답은 **"맞다, 그 순서가 된다"** 이다.
|
||||
> - **왜 반대여야 하는가**: index 2의 `StoreBind`가 index 3의
|
||||
> `TagHandler`를 *만들어낸* 주체다. 만든 쪽을 먼저 지우면 만들어진
|
||||
> 쪽을 정리할 주체가 사라진다 — 스택을 쌓은 역순으로 푸는 것과 같은
|
||||
> 이유(LIFO). 그래서 각 핸들러의 retractor는 자기가 재귀로 만든 하위
|
||||
> 인덱스를 쫓아갈 필요가 없다: **자기 차례가 왔을 땐 이미 아래가 다
|
||||
> 비어 있다.**
|
||||
> - **주의 — "3 → 2 → 1"은 한 `(inst,k)` 체인 안에서의 이야기다.**
|
||||
> 서로 다른 키(`(inst,k1)` vs `(inst,k2)`)는 완전히 별개 배열이고
|
||||
> 서로의 순서와 무관하다(아래 "인덱스의 의미" 항목).
|
||||
|
||||
디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출,
|
||||
첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라
|
||||
열린 레지스트리). tbox의 `TUnion` 런타임 체커가 이미 이 "순서대로 스캔, 첫 매치
|
||||
|
|
@ -152,10 +181,16 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
`base/module-lifecycle-plan.md`(모듈 표면)에도 반영이 필요하고,
|
||||
다중 인스턴스화(`New()`, `base/architecture.md` "확정된 결정" 13번) 시
|
||||
이 플래그가 인스턴스별인지 전역인지는 그때 같이 정한다.
|
||||
`Dispatch.listHandlers()`도 같은 디버그 표면에 속하는지(=플래그와 무관하게
|
||||
항상 호출 가능한지) 구현 시 정할 것.
|
||||
그리고 `Dispatch.listHandlers()`류
|
||||
함수로 현재 등록된 전체 핸들러(이름/priority)를 덤프할 수 있게 함.
|
||||
**[해소, 2026-08-20 구현 전 QA 4라운드 `D-8`] `Dispatch.listHandlers()`는
|
||||
`Quad.debug`와 무관하게 항상 호출 가능하다** — 옛 서술은 "같은 디버그 표면에
|
||||
속하는지 구현 시 정할 것"으로 열어뒀으나, 사용자 판정으로 닫힘: *"listHandlers
|
||||
는 항상 실행 가능. 유저가 필요하면 수행 시 목록들을 단순 반환해주고 출력하고
|
||||
싶다면 출력하는 용도임."* 즉 이 함수는 **아무것도 출력하지 않고 목록을 반환만
|
||||
하는 순수 조회**이고, 찍을지 말지는 호출한 쪽이 정한다 — 게이팅이 필요한 건
|
||||
"라이브러리가 스스로 콘솔에 쓰는" 동작(동률 경고 print)뿐이라 조회 함수는
|
||||
애초에 그 대상이 아니다.
|
||||
그래서 `Dispatch.listHandlers()`는 현재 등록된 전체 핸들러(이름/priority)를
|
||||
**반환**하는 함수로 둔다.
|
||||
구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라
|
||||
M2(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인
|
||||
`quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의,
|
||||
|
|
@ -353,10 +388,31 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
|
|||
postref 는 정확히는 다른, 더 위에 있는 for 문에서 처리되고"*). 두 보장은
|
||||
**서로 독립**이다 — `PreRef`가 먼저 도는 건 배열 파트 우선 규칙 때문이
|
||||
아니라 pre-pass가 따로 있기 때문. 일반 `Ref`(pre-pass 대상이 아닌 것)가
|
||||
프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효. **M0 스파이크에서 실제
|
||||
Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된
|
||||
항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로
|
||||
부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸).
|
||||
프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효.
|
||||
**[실측 완료, 2026-08-19 M0]** `luau-test/done/01-two-pass-array-hash-order.luau`가
|
||||
두 패스 드라이버를 최소 재현해 "array pass 전체가 항상 hash pass보다 먼저,
|
||||
array pass 안에서는 index 순서 정확" 을 확인함 — "M0에서 검증할 것"이던
|
||||
항목은 닫혔다.
|
||||
|
||||
**⚠️ [혼동 방지, 2026-08-20 구현 전 QA 4라운드 `D-10`] 이 실측은 "Luau가
|
||||
이 순서를 주는가"를 확인한 게 아니다.** 사용자 지적대로 **Luau의 일반화된
|
||||
반복 `for`는 이미 배열 파트를 먼저 훑고 해시 파트로 넘어간다** — 그건
|
||||
의심한 적이 없고, 2026-08-07에 사용자가 REPL로 직접 확인한 관찰이기도
|
||||
하다(`for i,v in {a=1, 2, b=3} do end` → `1,2` 다음 `a,b`).
|
||||
**그런데도 base 드라이버가 두 패스를 명시적으로 강제하는 이유는 순서를
|
||||
못 믿어서가 아니라 두 가지다**(위 (1)/(2)번 그대로):
|
||||
1. **이식성** — 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이
|
||||
아닌 자료구조로 표현할 수 있고, 그러면 "Lua 테이블의 순회 동작"이라는
|
||||
근거 자체가 없어진다. 계약을 드라이버가 직접 들고 있어야 한다.
|
||||
2. **어차피 구분 비용이 이미 든다** — 숫자 키와 문자열 키를 다른 의미로
|
||||
처리해야 하므로, 순서까지 명시적으로 고정하는 건 거의 공짜다.
|
||||
|
||||
즉 스파이크 `01`이 검증한 건 **"우리가 짠 두 패스 드라이버가 계약대로
|
||||
도는가"**이지 언어 동작이 아니다 — 그 스파이크 주석도 "사용자가 이미
|
||||
REPL로 확인했었지만(우연한 관찰), base는 이 우연한 동작에 기대지 않고
|
||||
… 두 패스를 명시적으로 강제하기로 확정함"이라고 같은 구분을 적어두고 있다.
|
||||
**`nil`-hole로 배열 파트가 통째로 해시 취급이 되는 케이스**(구멍 있는
|
||||
테이블)는 이것과 별개 문제이고 `02`/`06` 스파이크가 담당한다.
|
||||
|
||||
### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정)
|
||||
|
||||
|
|
@ -1588,7 +1644,14 @@ quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 n
|
|||
**`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드(2026-08-11
|
||||
세션 명시화)** — Slot이 마운트되는 시점(`Dispatch/Slot.luau`가
|
||||
`setOffsetSource`를 등록하는 바로 그 자리)에 같은 Source 객체를
|
||||
`self.Offset`으로도 저장, 마운트 전엔 `nil`. 위 정정대로 이 값을
|
||||
`self.Offset`으로도 저장. **[정정, 2026-08-20 구현 전 QA 4라운드 `D-60`/`SL-75`]
|
||||
마운트 전엔 `nil`이 아니라 `0`이고, 언마운트해도 `nil`로 되돌리지 않는다** —
|
||||
사용자 판정: *"마운트 전에는 0 이긴 함. 다만 list 의 관측으로 실체화된 값이
|
||||
나오는게 offset 설정 이후라서 그 땐 0 이 아닐 수 있을 뿐"*. 즉 `Offset`은 항상
|
||||
읽을 수 있는 `Source<number>`이고 마운트 전/언마운트 후엔 잠정값(`0` 또는 마지막
|
||||
값)을 들고 있을 뿐이다. `nil`로 갈아치우면 그 Source를 이미 구독 중인
|
||||
다운스트림이 끊겨 포탈이 깨진다 — 근거는 `base/slot-plan.md`의 "추가 방어 조치"
|
||||
항목. 위 정정대로 이 값을
|
||||
`LayoutOrder` 등에 실제로 반영하는 건 Slot 자신이 하지 않으므로,
|
||||
`:List`의 `updateFn`이 이 값을 받아 쓰거나(아래 `base/slot-plan.md`
|
||||
참고) 수동 CRUD 사용자가 직접 `slot.Offset`을 읽어 자기 원소 프로퍼티를
|
||||
|
|
|
|||
|
|
@ -155,7 +155,19 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
짝이다 — leaf 바인딩된 핸들에는 적용되지 않는다.** 아래 확장된 의미는
|
||||
**`:Subscribe()`로 등록한 핸들에 대해서만** 성립한다. `:Subscribe()`를
|
||||
부른 적 없는(=leaf 바인딩된) 핸들에 `:Unsubscribe()`를 지원하면 안 되거나,
|
||||
최소한 그 경로에서 cleanup을 앞당기면 안 된다. 사용자 판정: *"subscribe
|
||||
최소한 그 경로에서 cleanup을 앞당기면 안 된다.
|
||||
**[강화, 2026-08-20 구현 전 QA 4라운드 `E-11`] "안 되거나/최소한"이 아니라
|
||||
Observer와 정확히 같은 규칙으로 통일한다 — leaf 바인딩된 핸들에는
|
||||
`:Unsubscribe()`가 아예 안 먹는다.** 사용자 지적: *"옵저버에선 leaf
|
||||
바인딩에 Unsubscribe 못 하는것 처럼, Effect 또한 리프 바인딩에 있어서는
|
||||
Unsubscribe 안 먹어야 하는거 아님?"* — 맞다. `Observer`의
|
||||
`:Unsubscribe()`가 전역 경로 전용이고 leaf 해제는 `unbindLifetime`이
|
||||
담당한다는 게 이미 확정된 규칙인데(`base/source-state-plan.md`의 "이중
|
||||
바인딩 금지" 절), `Effect`만 애매하게 열어두면 두 프리미티브의 규칙이
|
||||
갈린다. **`State<Effect>` 재-dispatch와의 상호작용도 이 통일로 같이
|
||||
닫힌다** — leaf 바인딩된 핸들엔 `:Unsubscribe()`가 아예 안 먹으므로,
|
||||
아래 dedup 시나리오(값이 안 바뀌어 retract가 no-op인데 cleanup만
|
||||
앞당겨져 Effect가 조용히 죽는 것)가 발생할 경로 자체가 없어진다. 사용자 판정: *"subscribe
|
||||
한게 아니면 unsubscribe 는 지원하면 안 되거나, 적어도 리프 바운딩에선
|
||||
그래선 안 됨 … subscribe 는 unsubscribe 의 짝이라고 생각함."*
|
||||
- **왜 위험한가**: leaf 바인딩 + `State<Effect>`/`State<Observer>`
|
||||
|
|
|
|||
|
|
@ -284,6 +284,34 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
|
|||
것보다는 여전히 먼저** 불림. 이 캐비엇의 정본 서술은 `base/ref-plan.md`의
|
||||
"`PostRef`" 절 "보장 범위" 항목.
|
||||
|
||||
**[풀어쓰기, 2026-08-20 구현 전 QA 4라운드 `LH-8`]** 위 두 문단이 너무
|
||||
압축돼 있어 그것만 읽고는 판단이 안 된다는 지적이 있어서, 같은 내용을
|
||||
코드로 다시 적는다. `Frame { Frame { TextLabel {} , OnRendered(fn) } }`에서
|
||||
안쪽 `Frame`의 `fn`이 불릴 때:
|
||||
|
||||
```
|
||||
바깥 Frame { ... } ← ⑤ 이 호출은 아직 시작도 안 함
|
||||
└ 안쪽 Frame { ... } ← ④ 이 drive가 지금 끝나는 중
|
||||
├ TextLabel {} ← ① 이미 완성돼 안쪽 Frame에 붙어 있음
|
||||
└ OnRendered(fn) ← ③ fn이 여기서 불림
|
||||
(프로퍼티/이벤트) ← ② 이미 전부 세팅됨
|
||||
```
|
||||
|
||||
- **①② = "자기 아래"** — 안쪽 `Frame`의 자식(그리고 그 서브트리 전체)과
|
||||
자기 프로퍼티/이벤트. `fn`이 불릴 때 **전부 끝나 있다.**
|
||||
- **⑤ = "자기 위"** — 안쪽 `Frame`이 바깥 `Frame`의 자식이 되는 일.
|
||||
Lua 표현식 평가 순서상 **안쪽 `Frame{...}` 호출이 완전히 끝나야** 바깥
|
||||
`Frame`의 props 테이블이 완성되므로, `fn`이 불리는 시점엔 바깥 `Frame`은
|
||||
아직 존재하지도 않는다. 조상 체인 전체가 마찬가지다.
|
||||
- **그래서 "화면에 올라간 뒤"가 아니다** — 화면에 올라가려면 루트까지의
|
||||
조상 체인이 다 이어져야 하는데 그건 ⑤ 이후 일이다. React
|
||||
`componentDidMount`(DOM 삽입 **후**)를 기대하면 어긋난다.
|
||||
- **원래 뭐가 헷갈렸나**: 처음엔 경계를 "이 인스턴스의 프로퍼티만이냐(a),
|
||||
자식 서브트리까지냐(b)"로 놓고 "(a) 메커니즘으론 (b)를 못 준다"고
|
||||
판단했는데, 배열 파트 루프가 각 자식을 **동기적으로 끝내고** 넘어가므로
|
||||
①이 공짜로 따라온다 — (a)/(b)는 애초에 갈리는 지점이 아니었고, 실제로
|
||||
갈리는 건 ①②(자기 아래)와 ⑤(자기 위)였다.
|
||||
|
||||
**착수 시점 선택지 — (a) 확정.** 원래 (a)/(b)/(c) 셋을 열어뒀었고
|
||||
((b)는 일반 `Ref`로 근사, (c)는 계속 스코프 아웃), 사용자가 **(a)**
|
||||
(위 `PostRef` 스케치대로 두 패스 뒤 `postRefList` 소비)를 선택함.
|
||||
|
|
@ -336,6 +364,26 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
|
|||
기대하는 것과 미묘하게 다른 시점"**을 가리키므로, 문서화 시 위 ①
|
||||
절의 ⚠️ 항목을 반드시 같이 노출할 것(이름을 바꾸는 대신 문서로
|
||||
대응하기로 한 것 — 이름의 친숙함이 주는 이득이 더 크다는 판단).
|
||||
**[백로그 신설, 2026-08-20 구현 전 QA 4라운드]** 진짜
|
||||
`componentDidMount`(= 조상 체인까지 이어져 화면에 실제로 올라간 뒤)에
|
||||
해당하는 훅은 **지금 만들지 않되 백로그로 남긴다.** 사용자 메모:
|
||||
*"OnRendered 라는 이름이 componentDidMount 같은거 구현 가능하면 좋긴
|
||||
하겠는데 별로 애매한가 생각중... 화면 그려지기 전에 애니메이션이 된다던가
|
||||
하지 않게 하는 방안이 있음 좋아보임. 하지만 나중에 얹어져도 좋을
|
||||
이야기이고, 당장은 사용사례가 안 보이므로 추가 프리미티브에 백로깅만 하고,
|
||||
나중에 필요하다는 의견이 나오면 재생각 해볼 예정."*
|
||||
- **쓸모 있는 시나리오**: "화면에 그려지기 전에 애니메이션이 시작돼버리는"
|
||||
것을 막는 것 — 진입 애니메이션(`base/tween-plan.md`의 `initValue`,
|
||||
루트 `HUMAN_TODO.md` 10번)과 맞닿는 자리다.
|
||||
- **왜 지금 안 만드는가**: `PostRef`와 달리 **자기 위**를 알아야 하므로
|
||||
`Dispatch.drive` 한 번의 콜스택 안에서 표현할 수가 없고(부모의 drive가
|
||||
자식을 붙인 뒤에야 알 수 있음), 별도 전파 경로가 필요하다 — "공짜"가
|
||||
전혀 아니다. 그런데 구체적 사용 사례가 아직 안 보이므로
|
||||
`conventions.md`의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고
|
||||
구조를 복잡하게 만들지 않는다" 원칙대로 보류.
|
||||
- **요구가 나오면 재검토** — 그때 `OnRendered`와 이름을 어떻게 가를지도
|
||||
같이 정해야 한다(지금 `OnRendered`가 이미 그 이름을 점유 중이라
|
||||
혼동 위험이 있음).
|
||||
|
||||
## 패키지 배치
|
||||
|
||||
|
|
|
|||
|
|
@ -40,8 +40,32 @@ end
|
|||
```
|
||||
|
||||
연결 해제 시 `data.Signal = nil`만 하면 됨(`Connection.Dispose`) — 자료구조를
|
||||
바로 지우거나 재구성하지 않음. quad-v2도 이 모양을 그대로 채택: 라이프타임
|
||||
홀더는 "내가 아직 살아있게 하는 뒷받침 참조"가 nil인지만 확인하면 됨.
|
||||
바로 지우거나 재구성하지 않음.
|
||||
|
||||
> **⚠️ [전면 정정, 2026-08-20 구현 전 QA 4라운드 `LP-1`] quad는 이 rbvm 코드를
|
||||
> 채택하지 않는다 — quad에서 `Connected`는 "계산된 속성"이 아니라 그냥 Roblox
|
||||
> `RBXScriptConnection`의 네이티브 필드다.** 옛 서술("quad-v2도 이 모양을 그대로
|
||||
> 채택: 라이프타임 홀더는 '내가 아직 살아있게 하는 뒷받침 참조'가 nil인지만
|
||||
> 확인하면 됨")은 rbvm의 프록시 계층 사정을 quad에 잘못 옮긴 것이었다. 사용자
|
||||
> 판정: *"Connected 는 단순히 RBXScriptConnect 안의 속성이고, Destroy 수행 시
|
||||
> 모든 커넥션이 죽으니 자연스럽게 Connected 가 false 이 되는것 뿐임. nil로
|
||||
> 참조를 만들 이유도 없음."*
|
||||
>
|
||||
> **quad의 실제 판정은 두 상태뿐**(`isBoundAlive`, 아래 "(1)" 코드 블록):
|
||||
> 1. **gcconn 자체가 없음** — 아직 바인드 안 됐거나, 이미 GC돼서 weak 릴레이션
|
||||
> 항목이 비워진 상태. `BindData:GetWeak(value, "gcconn")`이 `nil`.
|
||||
> 2. **gcconn은 있는데 `.Connected == false`** — `inst`가 방금 Destroy됐고
|
||||
> 아직 GC는 안 된 구간. 엔진이 Destroy 시점에 모든 커넥션을 끊어주므로
|
||||
> quad가 아무것도 안 해도 이 값이 저절로 뒤집힌다.
|
||||
>
|
||||
> **quad가 `Signal = nil`처럼 직접 참조를 끊는 자리는 없다** — 유일하게 "직접
|
||||
> 끊는" 동작인 `unbindLifetime`도 `gchold[value] = nil`과 `BindData` 항목 제거일
|
||||
> 뿐 커넥션 자체를 만지지 않는다. rbvm에서 실제로 가져오는 건 **"gcconn 트릭으로
|
||||
> Instance 수명에 값을 매단다"는 관용구 하나**이고, `Connected`를 계산 속성으로
|
||||
> 만드는 구현은 가져오지 않는다.
|
||||
|
||||
rbvm에서 실제로 재사용하는 부분은 아래 "(0)"/"(1)" 절의 gcconn/gchold
|
||||
관용구이고, 위 `__index` 계산 속성 코드는 **참고용 원본 인용**으로만 남긴다.
|
||||
|
||||
### 2. Instance 파괴는 `Instance.Destroying` 훅 하나로만 관측
|
||||
|
||||
|
|
@ -50,6 +74,14 @@ rbvm은 실제 Roblox Instance의 파괴를 감지하는 지점을 단 하나로
|
|||
플래그를 그 콜백에서만 true로 뒤집음. `AncestryChanged`나 폴링 방식은 안 씀.
|
||||
quad-v2도 동일: 인스턴스 라이프사이클 훅 지점은 `Destroying` 하나로 통일.
|
||||
|
||||
**[구체화, 2026-08-20 구현 전 QA 4라운드 `LP-2`] "예상보다 적을 수 있다"가 아니라
|
||||
지금은 정확히 한 곳뿐이다 — `Effect`.** 아래 "2026-08-04 검증 라운드에서 보강된
|
||||
내용" 절이 "이 훅을 쓰는 지점이 예상보다 적을 수 있다"고만 열어뒀던 걸 사용자가
|
||||
확정해줌(*"당장은 Effect 뿐임"*). `Effect`의 leaf-death cleanup(`base/effect-plan.md`)이
|
||||
이 훅을 쓰는 유일한 소비자이고, 그 위의 슈가 `OnDestroyed`(`base/lifecycle-hooks-plan.md`)도
|
||||
결국 같은 경로다. 나머지(Observer 게이팅, Tag/Attribute 정리, Tween 취소)는 전부
|
||||
gcconn `Connected` 판정이나 엔진 자체 정리로 커버되어 이 훅을 안 씀.
|
||||
|
||||
### 3. 정리(`retract`)는 기본적으로 GC에 위임, 예외적으로만 즉시(eager)
|
||||
|
||||
rbvm 전역에 약한 테이블(weak table, `__mode = "k"/"v"/"kv"`)로 private 데이터를
|
||||
|
|
@ -324,6 +356,40 @@ function canExecute(value)
|
|||
end
|
||||
```
|
||||
|
||||
#### (1-1) ⚠️ 첫 인자가 물리 Instance가 아닐 수도 있다 — 백엔드가 반드시 핸들링할 것 (2026-08-20 구현 전 QA 4라운드 `D-56`)
|
||||
|
||||
**위 구현 스케치는 `inst`가 항상 Roblox Instance라고 가정하고 `InstData`에서
|
||||
gcconn/gchold를 찾는데, 실제 호출부 중엔 `inst` 자리에 `Slot`이 오는 경로가
|
||||
이미 있다.** `Dispatch.setLength(ownerKey, i, len)`이 그것 —
|
||||
`base/dispatch-core-plan.md`의 "`setLength` 구현" 절이 `bindLifetime(ownerKey,
|
||||
observer)`를 부르는데, 그 `ownerKey`는 Slot-in-Slot 중첩에서 **Slot 자신**이다
|
||||
(`base/slot-plan.md`의 "재귀 메커니즘" 절 — `attachSlot`이 `ownerKey`로 자기
|
||||
자신을 넘겨 최상위/중첩을 같은 함수로 통합한 그 설계).
|
||||
|
||||
**사용자 판정(2026-08-20)**: *"ownerKey 가 Slot일 수도 있음. 각 엔진의
|
||||
bindLifetime 은 이를 잘 핸들링 해줘야함. 즉, Slot안에, 또는 바깥에 SetStrong
|
||||
으로 gchold 비슷한걸 수행하면 됨."*
|
||||
|
||||
- **계약 두 개(위 절)는 그대로 유지된다** — 바뀌는 건 "그 계약을 무엇으로
|
||||
구현하는가"뿐. 물리 Instance면 gcconn 트릭이 두 계약을 다 만족시키고,
|
||||
Slot이면 **Slot 자신이 살아있는 동안 `value`를 붙잡는 강참조**(Slot 안의
|
||||
필드든, `Relate(slot)`에 `SetStrong`이든)와 **`value`가 그 Slot의 생존을
|
||||
되물을 수 있는 근거**를 백엔드가 제공하면 된다.
|
||||
- **왜 gcconn을 못 쓰는가**: gcconn 트릭은
|
||||
`inst:GetPropertyChangedSignal("ClassName")`에 의존하므로 엔진 객체가 아닌
|
||||
값(Slot은 평범한 Lua 테이블)엔 걸 수가 없다. Slot은 대신 **자기 자신이
|
||||
reachable한가**가 곧 생존이라, `Relate(slot)`가 weak-keyed인 것만으로
|
||||
"Slot이 죽으면 기록도 같이 사라진다"가 성립한다.
|
||||
- **`isBoundAlive`의 판정 분기도 이 경로를 알아야 함** — 지금 코드는
|
||||
gcconn이 없으면 곧바로 `.Subscribed` 폴백으로 떨어지는데, Slot-owned
|
||||
바인딩은 gcconn도 `.Subscribed`도 없어서 **살아있는데 `canBound`가 참으로
|
||||
잘못 나온다**(= 이중 바인딩 가드가 이 경로에선 안 걸림). 백엔드 구현이
|
||||
세 번째 분기를 추가하거나, Slot 쪽 홀더 존재 자체를 판정 근거로 삼아야 함.
|
||||
- **⚠️ 정확한 형태는 아직 미확정 — M2/M3 구현 시 확정할 것.** "Slot 안"(필드)
|
||||
이냐 "바깥"(`Relate`)이냐, `isBoundAlive`의 세 번째 분기를 어떤 모양으로
|
||||
둘지가 열려 있다. 지금 확정된 건 **"첫 인자가 Instance라고 가정하면 안
|
||||
된다"는 요구사항 자체**뿐.
|
||||
|
||||
**`bindLifetime`이 `value`와 맺는 계약은 정확히 둘**(이 둘이 위 구현의 전부):
|
||||
|
||||
1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다** — `gchold[value]`
|
||||
|
|
@ -513,9 +579,16 @@ quad는 자신이 만든 instance를 항상 끝까지 들고 있어서 이런
|
|||
Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부 알아서
|
||||
정리해준다 — 라이브러리가 따로 처리할 필요가 없음. Roblox 이외의 엔진에서
|
||||
이런 정리가 필요하다면 그건 그 엔진의 `quad-X` 서브패키지가 책임질 문제(base
|
||||
관심사 아님). 사용자가 커스텀 Destroy-time 처리가 필요하면 `[Event
|
||||
"Destroying"]`을 직접 바인드해서 처리하면 되는 구조라, 라이브러리가 강제로
|
||||
제공할 필요도 없음.
|
||||
관심사 아님). 사용자가 커스텀 Destroy-time 처리가 필요하면 **`Effect`(그리고 그 슈가
|
||||
`OnDestroyed`)를 쓰면 되는 구조**라, 라이브러리가 강제로 제공할 필요도 없음.
|
||||
**[정정, 2026-08-20 구현 전 QA 4라운드 `LP-4`]** 옛 서술은 여기 정상 경로를
|
||||
`[Event "Destroying"]`을 직접 바인드하는 것으로 적었는데, 그건 사용자가 엔진
|
||||
이벤트를 손으로 다루라는 뜻이 되어 quad가 이미 제공하는 프리미티브를 우회하는
|
||||
안내였다 — 사용자 판정: *"Effect 임. 그리고 그 슈거인 OnDestroyed 존재"*.
|
||||
`Effect(fn)`이 반환하는 cleanup이 leaf 사망 시 정확히 1회 불린다는 계약
|
||||
(`base/effect-plan.md`)이 정확히 이 용도이고, `OnDestroyed(fn)`은 그걸 감싼
|
||||
순수 팩토리다(`base/lifecycle-hooks-plan.md`). `[Event "Destroying"]`을 직접
|
||||
바인드하는 것도 물론 막히진 않지만 권장 경로가 아니다.
|
||||
|
||||
## 이름: `cleanup` → `retract`
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,20 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스
|
|||
가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라
|
||||
**디스패치 단계**에서 풀린다:
|
||||
|
||||
- **⚠️ [명시 추가, 2026-08-20 구현 전 QA 4라운드 `M-5`] `None`만이 명시적
|
||||
unsetter다 — `mod:TextColor3(nil)`은 "지운다"가 아니라 "그 필드가 아예 없는
|
||||
새 Modifier를 만든다"이다.** 사용자 지적: *"mod:TextColor3(nil) mod 그룹에서
|
||||
제거된걸 생성하게됨. None 이 오직 명시적 'unsetter' 임."* 둘의 관측 가능한
|
||||
차이는 **`Overridden`/flatten에서 갈린다**:
|
||||
- `mod:TextColor3(nil)` → 그 필드가 **없는** Modifier. 나중에
|
||||
`Modifier.Overridden(other, mod)`로 합치면 `other`의 `TextColor3`가 **그대로
|
||||
살아남는다**(덮어쓸 값 자체가 없으므로).
|
||||
- `mod:TextColor3(None)` → 그 필드가 **`None`이라는 실재값으로 채워진**
|
||||
Modifier. 같은 `Overridden`에서 `other`의 값을 **이긴 뒤**, 디스패치 단계의
|
||||
`NoneHandler`가 실제로 지운다.
|
||||
- `:Peek(key)`도 이 둘을 구별해서 돌려준다(`nil` vs `None`) — 위
|
||||
"`:Peek`의 반환 타입" 항목이 `T | State<T> | None | nil`인 이유가 정확히
|
||||
이것. 구현은 이미 이렇게 되겠지만 **문서화에서 이 구분을 반드시 짚을 것**.
|
||||
- **`{ TextColor3 = None, mod }`도, `mod:TextColor3(None)`도 둘 다 지원.**
|
||||
Modifier setter/Overridden/인라인 props 테이블은 `None`을 그냥 평범한 raw
|
||||
값으로 저장·교체할 뿐 특별 취급이 전혀 없음 — 애초에 문제였던 건 "`nil`이
|
||||
|
|
@ -152,7 +166,15 @@ mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제
|
|||
current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 리터럴,
|
||||
이전 값을 바탕으로 계산하고 싶을 때는 변환 함수 하나로 충분.
|
||||
|
||||
**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).** 애초에 getter가
|
||||
**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).**
|
||||
**[관계 명시, 2026-08-20 구현 전 QA 4라운드 `M-9`] 다만 아래 9번 절의
|
||||
`:Peek(key)`가 사실상 getter 역할을 한다** — 사용자 지적(*"사실 Peek 가
|
||||
게터라고 봐도 되긴 함"*). 이 절이 "안 만든다"고 한 건 **setter와 짝을 이루는
|
||||
필드별 getter**(`:FontSize()` 무인자 호출로 값을 꺼내는 모양, 또는 dot-access
|
||||
겸용)이고, `:Peek`은 그것과 달리 **키를 인자로 받는 단일 범용 접근자**이며
|
||||
설계 동기도 다르다(setter 인체공학이 아니라 `Apply` 팩토리가 현재 상태를
|
||||
읽어야 해서). 두 서술이 모순이 아니라는 걸 문서화 시 같이 밝힐 것 —
|
||||
"getter가 없다"만 읽으면 `:Peek`의 존재와 부딪히는 것처럼 보인다. 애초에 getter가
|
||||
필요했던 유일한 이유가 "현재 값을 꺼내서 여러 줄에 걸쳐 계산한 뒤 리터럴로
|
||||
다시 넣는" 멀티라인 스타일이었는데, `:FontSize(function(old) ... end)`
|
||||
변환 함수 하나가 그 케이스를 인라인으로 완전히 커버함 — 별도 `:Get(key)`/
|
||||
|
|
|
|||
|
|
@ -253,8 +253,10 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
|
|||
게 기본이어야 함. 켜는 건 명시적 opt-in.
|
||||
- **다중 인스턴스화 시 인스턴스별인지 전역인지는 미정** — 위 "모듈 스코핑"
|
||||
절과 같이 정할 것.
|
||||
- `Dispatch.listHandlers()`류 조회 함수가 같은 디버그 표면에 속하는지도
|
||||
같이 정할 것(조회는 부작용이 없으니 항상 열어둬도 무방해 보임).
|
||||
- **[해소, 2026-08-20 구현 전 QA 4라운드 `D-8`/`ML-9`] `Dispatch.listHandlers()`는
|
||||
이 플래그와 무관하게 항상 호출 가능하다** — 순수 조회(목록 **반환**만 하고
|
||||
스스로 출력하지 않음)라 게이팅 대상이 아니다. 출력할지는 호출부가 정한다.
|
||||
상세는 `base/dispatch-core-plan.md`의 "우선순위 동률/매치 실패 처리" 절.
|
||||
|
||||
## Quad는 스크립트인가 라이브러리인가 (확정, 참고용)
|
||||
|
||||
|
|
|
|||
|
|
@ -150,10 +150,22 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
|
|||
원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을
|
||||
서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능
|
||||
(`type(v) == "thread"` → 대기자, `type(v) == "function"` → 콜백,
|
||||
`nil` → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 `table.insert`가
|
||||
아니라 **비어있는(=`nil`인) 첫 슬롯을 선형 탐색해 재사용**하는
|
||||
등록 함수로 함(아래 참고) — 소진된 슬롯이 실제로 비므로 등록이 그
|
||||
자리를 되찾아 쓸 수 있음.
|
||||
`nil` → 빈 슬롯이라 스킵). **[정정, 2026-08-20 구현 전 QA 4라운드 `R-11`]
|
||||
새 콜백/대기자 등록은 그냥 `table.insert`를 쓴다** — 옛 서술은 "`table.insert`가
|
||||
아니라 비어있는 첫 슬롯을 선형 탐색해 재사용하는 등록 함수"였는데, 사용자
|
||||
판정으로 뒤집힘: *"table.insert 자체가 가장 처음 nil 이 등장하는 인덱스에
|
||||
넣어주기에 table.insert 가 맞음 … None 으로 바꾸면 무한정 불어나지만,
|
||||
nil이면 그렇지 않음."*
|
||||
- 즉 소진으로 생긴 구멍을 `table.insert`가 알아서 되찾아 쓰므로, 별도
|
||||
선형 탐색 등록 함수를 손으로 만들 이유가 없다. 아래 "왜 `None`이 아니라
|
||||
`nil`인가" 절이 들던 "`table.insert`의 `#t`가 구멍 있는 테이블에서
|
||||
미정의"라는 회피 근거도 이 배열에 대해서는 실무상 성립하지 않는 것으로
|
||||
정정 — 그 절의 결론(`nil` 소진)은 그대로이고 **우회 방법만 단순해짐**.
|
||||
- **⚠️ 실측 대상(M0/M8)**: 구멍 있는 테이블에서 `#t`가 반환하는 border는
|
||||
Lua 명세상 "어떤 border든" 이므로, "항상 첫 `nil` 자리"가 Luau 구현에서
|
||||
실제로 그렇게 나오는지는 스파이크로 확인할 것 — `luau-test`에 등록
|
||||
함수/소진을 반복하는 케이스를 추가. 결과가 다르면 이 항목만 되돌리면
|
||||
되고(옛 선형 탐색 버전), 나머지 설계엔 영향이 없다.
|
||||
- **왜 `None`이 아니라 `nil`인가(2026-08-09 열한 번째 세션, 최종 정정)
|
||||
— 2026-08-07 열 번째 세션에 `None`으로 바꿨던 것은 이 배열에는 안
|
||||
맞는 처방이었음, 되돌림.** `None`을 도입한 원래 근거(구멍 있는
|
||||
|
|
|
|||
|
|
@ -91,12 +91,15 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 "
|
|||
붙는 동적 리스트라 이 전제 자체가 없음** — Slot 안의 Ref가 "무엇"을
|
||||
가리켜야 하는지 정의가 안 됨. 대체 경로도 이미 있어 능력 손실 없음 —
|
||||
특정 child에 ref가 필요하면 그 child를 만드는 컴포넌트 호출 자체에
|
||||
Ref를 넘기면 됨(`slot:Add(Frame { Ref = myRef })` — **여기서 `Frame`은
|
||||
`Ref`라는 named 파라미터를 받는 컴포넌트 함수다.** Instance 리터럴에
|
||||
`Ref = ...`를 named 키로 놓는 건 leaf 바인딩이 아니고, 실제로는
|
||||
`HANDLER_PRIORITY_FALLBACK` 가드가 잡아 에러를 낸다 — leaf 바인딩은
|
||||
배열(숫자 키) 전용, `base/ref-plan.md`. **[명시 추가, 2026-08-18 구현 전
|
||||
QA]**).
|
||||
Ref를 넘기면 됨(`slot:Add(MyComponent { Ref = myRef })` — **여기서
|
||||
`MyComponent`는 `Ref`라는 named 파라미터를 받는 사용자 컴포넌트 함수다.**
|
||||
**[예시 이름 정정, 2026-08-20 구현 전 QA 4라운드 `SL-4`]** 예전엔 이 자리를
|
||||
`Frame {...}`으로 적었는데, `Frame`은 코퍼스 전반에서 **인스턴스 리터럴**을
|
||||
가리키는 이름이라 "리터럴의 named 키에 Ref를 놓아도 된다"로 읽히는
|
||||
정반대 오해를 부른다 — 실제로 인스턴스 리터럴에 `Ref = ...`를 named 키로
|
||||
놓으면 leaf 바인딩이 **아니고** `HANDLER_PRIORITY_FALLBACK` 가드가 잡아
|
||||
에러를 낸다(leaf 바인딩은 배열 숫자 키 전용, `base/ref-plan.md`).
|
||||
컴포넌트 함수임이 이름에서 바로 드러나도록 예시 이름을 바꿈).
|
||||
- **`T`의 실제 의미**: 위 배제 덕에 "이 Slot이 실제로 담을 수 있는 최종
|
||||
마운트 가능한 값의 타입" 그 자체로 단순해짐 — quad-roblox엔 사실상
|
||||
`T = Instance` 하나뿐(컴포넌트 호출 결과도 결국 Instance)이라
|
||||
|
|
@ -1690,7 +1693,9 @@ local function unmountSlotTree(slot)
|
|||
end
|
||||
end
|
||||
slot._mounted, slot._mountedInst = false, nil
|
||||
slot.Offset = nil -- 마운트 전 상태로 복원(위 "`Slot.Offset`은 마운트 전엔 nil")
|
||||
-- [정정, 2026-08-20 `SL-75`] slot.Offset은 건드리지 않는다 — nil로 되돌리면
|
||||
-- 그 Source를 이미 구독 중인 다운스트림이 영구히 끊긴다(포탈이 깨짐).
|
||||
-- stale한 채 남겨두고, 재마운트 시 setOffsetSource의 즉시 계산이 덮어쓴다.
|
||||
-- slot 자신의 unbindLifetime / releaseOwner / owner쪽 setLength·setOffsetSource는
|
||||
-- 호출부 몫 — destroySlotTree와 동일한 층위 분리.
|
||||
end
|
||||
|
|
@ -2079,8 +2084,16 @@ end
|
|||
Slot처럼 "죽는 순간 `elementOwner`/`lengthList`/`sourceList`가
|
||||
어긋나는" 트리 부기 자체가 없음. 즉 dispose가 막으려는 문제(부기
|
||||
붕괴)가 Observer/Effect에는 원천적으로 발생하지 않음 — 아무도 안 들고
|
||||
있으면 그냥 GC, 조기에 끊고 싶으면 `unbindLifetime`으로 충분하고
|
||||
`dispose`가 다룰 이유가 없음.
|
||||
있으면 그냥 GC. **[정정, 2026-08-20 구현 전 QA 4라운드 `SL-72`] 조기에 끊는
|
||||
사용자 경로는 `unbindLifetime`이 아니다** — 옛 서술은 "조기에 끊고 싶으면
|
||||
`unbindLifetime`으로 충분"이라 적어 이걸 사용자 API처럼 안내했는데, 사용자
|
||||
판정: *"우린 조기에 끊는걸 명시적으로 unbindLifetime 로 지원하지 않음. 그건
|
||||
유저에게 드러나는 표면이 아니고, State<Observer?> 를 사용하는게 적절."*
|
||||
`bindLifetime`/`unbindLifetime`은 **Handler 작성자용 내부 배관**이고
|
||||
(`base/lifecycle-pattern.md`), 사용자가 leaf에 붙은 Observer/Effect를 끄고
|
||||
싶으면 그 자리를 `State<Observer?>`로 두고 `nil`을 emit하면 된다 — 그러면
|
||||
기존 하강 diff가 알아서 이전 것을 retract한다(새 경로 불필요). 어느 쪽이든
|
||||
`dispose`가 다룰 이유가 없다는 결론은 그대로.
|
||||
**주의 — Modifier 필드/`Slot:Add`·`:List` 원소 금지 규칙("핸들러 계층 값이
|
||||
들어오면 즉시 error")과 헷갈리지 말 것.** 그건 Modifier 필드나 Slot의
|
||||
CRUD 원소 자리에 관한 별개 규칙이고, children 배열의 leaf 위치(정적
|
||||
|
|
@ -2119,9 +2132,24 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기
|
|||
`Source` 전용 변형이 필요한지 같이 정해야 한다.
|
||||
2. `Source`에 콜론 메서드로 직접 얹기(`source:SetAndDispose(new)`).
|
||||
|
||||
**미결**: 어느 쪽을 택할지, 그리고 이번 범위에 넣을지 백로그로 뺄지.
|
||||
`state:Apply`의 시그니처(`(State<T>) -> U`)에 영향이 갈 수 있으므로 **M3
|
||||
착수 전에 방향만이라도 정해둘 것**. `question.md`에 올려둠.
|
||||
**[해소, 2026-08-20 구현 전 QA 4라운드 `SL-74`] 2번(`Source`의 콜론 메서드)으로
|
||||
확정 — `state:Apply` 시그니처엔 영향 없음.** 사용자 판정: *"타입 문제 때문에
|
||||
Apply 를 오버라이딩 해서 source 타입을 함수에 건내주는건 못함. 그럼 source ->
|
||||
state 가 안전히 성립 못해서, Apply 라는 이름을 그대로 쓰지는 못함. 따라서 영향이
|
||||
안 가고, 그냥 SetAndDispose() 로만 Set() 와 세트로 주는게 나아보이고, 그걸로
|
||||
확정지어야할것 같다는 생각임."*
|
||||
|
||||
- **1번(`source:Apply(SetAndDispose(new))`)이 기각된 이유는 타입이다** —
|
||||
`state:Apply(factory)`는 `factory`에 `State<T>`를 넘기는 것으로 이미 확정돼
|
||||
있는데(`base/source-state-plan.md`), `Source` 전용으로 오버라이딩하면 같은
|
||||
이름이 리시버 타입에 따라 다른 걸 넘기게 된다. `Source<T>`가 `State<T>`를
|
||||
**단방향으로만** 만족하므로 그 반대 방향(넘겨받은 게 `Source`임을 보장)은
|
||||
안전하게 성립하지 않는다 — 이름을 그대로 재사용할 수가 없음.
|
||||
- **확정 형태**: `source:SetAndDispose(value)` — `:Set(value)`와 **한 세트로
|
||||
묶인 `Source` 전용 콜론 메서드**. `Set`(언마운트) → 옛 값 `dispose` 순서를
|
||||
안에서 수행하므로 호출부가 `Get()`으로 옛 값을 미리 잡아둘 필요가 없다.
|
||||
- **`state:Apply`는 손대지 않는다** — 시그니처 영향이 없으므로 M3 착수 전
|
||||
결론이 필요하던 항목에서 빠진다(`question.md`/`.claude/todos.md`에서 제거).
|
||||
|
||||
#### 구현상 바뀌어야 하는 것
|
||||
|
||||
|
|
@ -2151,7 +2179,8 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기
|
|||
|
||||
`unmountSlotTree`는 `destroySlotTree`가 하는 일 중 **실제 파괴와 자식
|
||||
소유권 반납만 빼고 나머지는 그대로 함**(자식 observer `unbindLifetime`,
|
||||
`_mounted`/`_mountedInst` 복원, `slot.Offset = nil`). 옛 owner에 등록해둔
|
||||
`_mounted`/`_mountedInst` 복원 — **[정정, 2026-08-20 `SL-75`] `slot.Offset`은
|
||||
안 건드림**). 옛 owner에 등록해둔
|
||||
`Dispatch.setLength`/`setOffsetSource` 해제는 호출부 몫(아래).
|
||||
|
||||
**[정정, 사용자 지적] "해제 짝"이라는 새 API는 필요 없음** — 옛 owner에
|
||||
|
|
@ -2188,10 +2217,27 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기
|
|||
오류로 이어지진 않지만, 방어적으로 순서를 고정.
|
||||
|
||||
**추가 방어 조치**:
|
||||
- **해제 시 `slot.Offset = nil`도 같이** — 이 문서의 "`Slot.Offset`은
|
||||
마운트 시점에 세팅되고 마운트 전엔 `nil`" 규칙과 짝을 맞춤. 안 그러면
|
||||
떼어낸 Slot이 옛 owner 기준의 stale한 `Offset`을 계속 공개해, 그걸
|
||||
읽는 사용자 코드가 조용히 틀린 값을 씀.
|
||||
- **⚠️ [전면 정정, 2026-08-20 구현 전 QA 4라운드 `SL-75`/`D-60`] 해제 시
|
||||
`slot.Offset`을 `nil`로 되돌리면 안 된다 — stale한 채로 그냥 둔다.**
|
||||
옛 서술은 "해제 시 `slot.Offset = nil`도 같이"였는데, 그러면 **포탈(언마운트
|
||||
후 다른 곳에 재마운트)이 깨진다** — 사용자 판정: *"nil 로 만들면 안 되는게,
|
||||
포탈로 옮기는게 안 됨. 이미 offset 을 들고 가 바운딩 했다면 큰 문제가 생김.
|
||||
그냥 stale하게 있는게 맞고, 나중에 offset이 멀쩡히 다시 설정되는게 옳음.
|
||||
언마운트 시 offset stale 은 단순히 맞는 행동이고, 처음 생성 시 0 인것과 유사
|
||||
동작임."*
|
||||
- **누가 이미 그 `Source`를 구독하고 있을 수 있다** — `updateFn`이
|
||||
`layoutOrder:With(offset):Compute(...)`처럼 `offset`을 파생 그래프에 엮어둔
|
||||
상태에서 필드를 `nil`로 갈아치우면, 그 구독은 옛 Source를 계속 보는데
|
||||
Slot은 새 Source를 만들어 등록하게 되어 둘이 영영 갈라진다. 재마운트가
|
||||
"값이 다시 정상으로 채워지는" 일이 아니라 "연결이 끊긴 채 조용히 멈추는"
|
||||
일이 되어버림.
|
||||
- **stale한 값 자체는 위험하지 않다** — 언마운트 상태의 `Offset`은 그냥
|
||||
"마지막으로 알던 위치"이고, 재마운트되면 `setOffsetSource`의 즉시 계산이
|
||||
올바른 값으로 덮어쓴다. 이건 **처음 생성 시 `0`인 것과 같은 성격의
|
||||
잠정값**이지 오염이 아니다.
|
||||
- **따라서 `Slot.Offset`은 "마운트 전엔 `nil`"이 아니다** — 아래
|
||||
"`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드" 관련 서술과
|
||||
`unmountSlotTree` 의사코드도 이 정정에 맞춰 갱신됨(같은 라운드).
|
||||
- **`recompute`는 `sourceList[i]`가 `None`이든 `nil`이든 "참여 안 함"으로
|
||||
똑같이 관대하게 넘어갈 것** — 정상 상태에선 항상 `None`으로 채워지는 게
|
||||
계약이지만(`nil`은 배열에 구멍을 냄), 해제/재마운트가 얽히는 전이
|
||||
|
|
|
|||
|
|
@ -449,6 +449,37 @@ Tween(opts: {
|
|||
}) -> Tween<T>
|
||||
```
|
||||
|
||||
## `Tween<T>:Map(fn)` — 값만 갈아끼운 새 `Tween`을 반환 (2026-08-20 구현 전 QA 4라운드 `UI-8` 신설)
|
||||
|
||||
**동기**: `base/ui-shorthand-plan.md`의 숏핸드가 스칼라를 자식 프로퍼티 타입으로
|
||||
감싸야 하는데(`UICorner = 8` → `CornerRadius = UDim.new(0, 8)`), 값이
|
||||
`Tween<number>`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만** 적용해야 한다.
|
||||
그 문서가 `mapTweenValue(v, wrap)`라는 로컬 헬퍼로 적어뒀던 것을, 사용자 판정으로
|
||||
**`Tween` 자신의 공개 메소드로 승격**한다: *"그냥 펑터 구조를 그대로 줘도
|
||||
무방한듯. :Map 정도로써 새 Tween 을 새 관측된 Value 로 형성."*
|
||||
|
||||
```lua
|
||||
tween:Map(fn: (T) -> U): Tween<U> -- opts를 clone하고 Value만 fn(Value)로 교체해 새 Tween 반환
|
||||
```
|
||||
|
||||
- **타입이 안전하게 성립한다** — `Tween<T>`는 immutable raw 값이고 `Value` 외의
|
||||
필드는 값 타입과 무관한 옵션(`Time`/`Style`/…)이라, `Value`만 `U`로 바꾼
|
||||
`Tween<U>`를 만드는 건 타입 레벨에서 깨끗하다.
|
||||
- **`Tween<T>`가 immutable이라는 기존 확정과 일관** — `:Map`은 원본을 안 건드리고
|
||||
`table.clone` 후 `Value`만 교체해 `Tween(opts)`로 다시 만든다(`Tag`/`Modifier`의
|
||||
clone 체이닝과 같은 계열).
|
||||
- **부수 효과 — 기본 `Tween` 정의를 만들어두고 재사용하는 패턴이 열린다.**
|
||||
`local FAST = Tween{Value = 0, Time = 0.15}` 같은 상수를 두고
|
||||
`FAST:Map(function() return targetPos end)`처럼 옵션만 재사용할 수 있다. 다만
|
||||
**사용 케이스가 넓지는 않을 것**으로 봄 — 어차피 내부 구현에 필요해서 만드는
|
||||
것이고, 외부에 보이는 게 무해하니 같이 공개하는 것뿐(사용자 판단).
|
||||
- **이름 — `Map` 또는 `Mapped`.** 코퍼스의 `-ed` 관례("clone 후 즉시 확정된 값"은
|
||||
`Added`/`Removed`/`Overridden`처럼 과거분사, `base/source-state-plan.md`의
|
||||
"네이밍 — `Compute`가 `-ed`가 아닌 이유" 절)를 그대로 적용하면 **`Mapped`가
|
||||
더 일관적**이다 — `Tween`은 lazy가 아니라 즉시 확정되는 raw 값이므로.
|
||||
사용자도 둘 다 열어둠(*"혹은 Mapped 의 immutable 의 ed 형태를 써도 좋아보임"*)
|
||||
— **⚠️ 최종 이름은 미확정**, 코퍼스 일관성만 보면 `Mapped` 쪽이 맞다.
|
||||
|
||||
## 네임스페이스드 객체 (더 이상 유효한 관심사 아님)
|
||||
|
||||
기존 모델(핸들러가 대상을 이름으로 찾아야 하는 가능성)을 염두에 두고
|
||||
|
|
|
|||
|
|
@ -96,11 +96,21 @@ ref 저장보단 비쌈. spring 등으로 움직일 수도 있다 생각하면
|
|||
디버깅 가시성(`research/debug-tooling-plan.md`)과 "사용자가 만든
|
||||
`UICorner`를 건드리지 않는다"는 위 판정에 여전히 필요하다. **이름은
|
||||
표시·판정용, `Relate`는 조회용**으로 역할이 갈린다.
|
||||
- **⚠️ 확인 필요 — `inst`-키 `Relate`의 전제**: `inst`를 키로 쓰는
|
||||
`Relate` 전체가 "Instance 생성 시점에 gcconn/gchold를 심어 userdata
|
||||
동일성을 고정한다"는 셋업 위에서만 성립한다(`base/lifecycle-pattern.md`).
|
||||
숏핸드가 만드는 **자식**도 quad가 만든 Instance이므로 그 셋업을 거치는지
|
||||
구현 시 확인할 것 — 안 거치면 여기서만 조용히 미아가 된다.
|
||||
- **[해소, 2026-08-20 구현 전 QA 4라운드 `UI-5`] 자식도 반드시 gcconn/gchold
|
||||
셋업을 거친다 — 안 거치면 이 숏핸드 자체가 애초에 동작을 못 한다.** 원래
|
||||
"구현 시 확인할 것"으로 열어뒀던 항목인데, 사용자 판정으로 닫힘: *"애초에
|
||||
똑같이 process 로 위임하는 이상, gcconn/gchold 없으면 옵저버 바인딩 부터
|
||||
실패함. 일반 요소처럼 똑같이 UI...{} 처럼 생성되어도 되고, 어떤 방식으로든
|
||||
gcconn/gchold 가 셋업되는게 맞음."*
|
||||
- **논거**: 아래 "Tween 지원" 절대로 이 숏핸드는 자식 프로퍼티를 직접 쓰지
|
||||
않고 `Dispatch.process(child, prop, v, 1)`로 위임하는데, 그 값이
|
||||
`State`면 `StoreBind`가 `bindLifetime(child, observer)`를 부른다 — 그
|
||||
시점에 `child`에 gcconn/gchold가 없으면 **거기서 곧바로 실패**한다.
|
||||
즉 "조용히 미아가 되는" 시나리오가 아니라 **즉시 드러나는 전제 조건**이고,
|
||||
숏핸드가 자식을 만드는 경로도 일반 인스턴스 생성과 같은 셋업을 타야 한다.
|
||||
- **따라서 `ensureManagedChild`는 자식을 만들 때 일반 인스턴스 생성과
|
||||
동일한 경로**(gcconn/gchold 셋업 포함)를 써야 한다 — 별도 확인 항목이
|
||||
아니라 구현이 반드시 만족해야 하는 계약.
|
||||
- **부수 요구 — 숏핸드가 만든 자식의 프로퍼티 세팅도 `Dispatch`에 위임**:
|
||||
*"각 숏핸드가 만들어낸 요소의 프로퍼티 세팅은 새로운
|
||||
dispatch.process(target,k,v) 로 위임해 tween 등이 자연스럽게 가능."*
|
||||
|
|
@ -179,7 +189,14 @@ end
|
|||
State 레이어를 먼저 다 풀어내므로 이 Handler가 실제로 보는 `v`는
|
||||
`number` 아니면 `Tween<number>` 둘 중 하나.
|
||||
|
||||
**한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.** 숏핸드는
|
||||
**한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.**
|
||||
**[승격, 2026-08-20 구현 전 QA 4라운드 `UI-8`] 아래 로컬 헬퍼 `mapTweenValue`는
|
||||
`Tween` 자신의 공개 메소드 `:Map(fn)`(이름 후보 `:Mapped`)으로 올라갔다** —
|
||||
`base/tween-plan.md`의 "`Tween<T>:Map(fn)`" 절이 소스. 이 문서에 로컬 헬퍼로
|
||||
두면 같은 변환이 다른 숏핸드/백엔드에서 또 복제되므로, 값 타입 자신이 제공하는
|
||||
게 맞다는 사용자 판단. 아래 스케치는 그 메소드가 하는 일을 풀어 쓴 것으로만
|
||||
읽을 것(`isTween(v)` 분기는 호출부에 남고, `Tween`이면 `v:Map(wrap)`, 아니면
|
||||
`wrap(v)`): 숏핸드는
|
||||
"스칼라를 받아 자식 프로퍼티 타입으로 감싸는" 변환을 갖고 있음(`UICorner = 8`
|
||||
→ `CornerRadius = UDim.new(0, 8)`, 열린 질문 절의 룩업 테이블 `wrap=fn`).
|
||||
`v`가 `Tween<number>`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만**
|
||||
|
|
@ -215,14 +232,24 @@ PropertyHandler의 "첫 세팅은 애니메이션 없이 즉시"(`prev == nil`)
|
|||
상황 — 계속 애니메이션되길 원하면 자식이 살아있도록 `nil`로 내리지 말고
|
||||
값만 바꿀 것.
|
||||
|
||||
**자식을 없앨 때의 정리 책임은 이 Handler에 있음** — `v`가 `nil`이 되거나
|
||||
retractor가 `nil` 힌트로 불려 자식을 파괴할 때, 실행 중인 엔진 Tween이
|
||||
남아있을 수 있으므로 `Dispatch.retractFrom(child, prop, 1)`을 같이
|
||||
부르는 게 정석(자식 Instance를 `Destroy`하면 엔진 트윈도 같이 죽고
|
||||
`chains`도 weak-keyed라 결국 GC되지만, "즉시" 끊는 건 명시적 호출뿐).
|
||||
`retractor` 안에서 **다른 키**에 대한 `retractFrom`을 부르는 건 허용된
|
||||
경로임(`base/dispatch-core-plan.md`의 retract 계약 — 금지된 건 같은
|
||||
`(inst,k)`에 대한 재진입).
|
||||
**[정정, 2026-08-20 구현 전 QA 4라운드 `UI-11`] 자식을 파괴할 때
|
||||
`Dispatch.retractFrom(child, prop, 1)`을 "정석"으로 요구하지 않는다 — 실익이
|
||||
없다.** 옛 서술은 "실행 중인 엔진 Tween이 남아있을 수 있으므로 같이 부르는 게
|
||||
정석"이었는데, 사용자 판정: *"자식 파괴 시 사실 Tween 은 엔진에 의해 자동
|
||||
멈춤/무효/삭제 처리되고, 트윈 자체가 retract 되어도 아무것도 안 하는 nop 라
|
||||
의미가 없을것이다."*
|
||||
|
||||
- **두 겹으로 무의미하다**: (a) Roblox 엔진이 Destroy 시점에 그 인스턴스에
|
||||
걸린 Tween을 알아서 정리하고(`base/lifecycle-pattern.md`의 "retract는 Destroy 시점에 필요 없는 이유가
|
||||
엔진 레벨에서 한 번 더 보강됨" 절), (b) `PropertyHandler`가 반환하는 retractor는 애초에
|
||||
몸체가 no-op이라(`base/tween-plan.md`의 "왜 `retract`가 더 이상 필요
|
||||
없는가" 절) 불러봐야 하는 일이 없다.
|
||||
- **그래도 호출이 금지되는 건 아니다** — `retractor` 안에서 **다른 키**에
|
||||
대한 `retractFrom`을 부르는 것 자체는 여전히 허용된 경로다
|
||||
(`base/dispatch-core-plan.md`의 retract 계약 — 금지된 건 같은 `(inst,k)`에
|
||||
대한 재진입). 다만 이 자리에서 **필요하지 않다**는 것.
|
||||
- `chains`는 `child`에 대해 weak-keyed라 자식을 버리면 결국 GC된다 — 명시적
|
||||
정리가 필요한 자원이 이 자리엔 없다.
|
||||
|
||||
## store-bind — 이 숏핸드도 지원
|
||||
|
||||
|
|
|
|||
|
|
@ -179,16 +179,10 @@
|
|||
같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별
|
||||
claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼
|
||||
`bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야
|
||||
하므로), **키를 무엇으로 할지**(`(inst, groupValue) → k`인지 `groupKey`
|
||||
단위인지)와 기존 `nameClaims`와의 공존 방식이 미정 —
|
||||
하므로), **[2026-08-20 QA 4라운드] 이름도 `groupClaimKeys`로 확정**.
|
||||
남은 건 **키를 무엇으로 할지**(`(inst, groupValue) → k`인지 `groupKey`
|
||||
단위인지)와 기존 `nameClaims`와의 공존 방식 —
|
||||
`base/attribute-plan.md`의 "이름 소유권" 절.
|
||||
- **[신설, 2026-08-18 구현 전 QA] `SetAndDispose` 류 편의 콤비네이터** —
|
||||
`Get()` → `Set(new)` → 옛 값 `dispose`의 3단계를 매번 손으로 쓰는 게
|
||||
불편하다는 사용자 지적에서 나옴. `source:Apply(SetAndDispose(new))`
|
||||
(단 이때 `Apply`는 `State`가 아니라 `Source`를 넘겨야 함)와
|
||||
`source:SetAndDispose(new)` 콜론 메서드 중 어느 쪽인지, 그리고 이번
|
||||
범위인지 백로그인지 미정 — **M3 착수 전 방향만이라도** 정할 것
|
||||
(`state:Apply` 시그니처에 영향), `base/slot-plan.md`의 `dispose` 절.
|
||||
- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State →
|
||||
State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도
|
||||
없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가
|
||||
|
|
|
|||
|
|
@ -65,8 +65,10 @@
|
|||
하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.**
|
||||
- **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) —
|
||||
방향은 확정, 키 설계가 미정. M10 착수 전 필요.
|
||||
- **`SetAndDispose` 방향**(`base/slot-plan.md`) — `state:Apply`
|
||||
시그니처에 영향이 갈 수 있어 M3 착수 전 방향만이라도.
|
||||
- **[2026-08-20 해소]** `SetAndDispose` 방향 — **`source:SetAndDispose(value)`
|
||||
콜론 메서드로 확정**(`:Set`과 한 세트). `state:Apply` 시그니처엔 영향
|
||||
없음(`Apply` 오버라이딩은 `Source`→`State` 단방향 때문에 타입이 안
|
||||
성립해서 애초에 불가). `base/slot-plan.md`의 `dispose` 절.
|
||||
- **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md`
|
||||
`:Unsubscribe()` 절) — M3 착수 전 확인.
|
||||
- **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면
|
||||
|
|
|
|||
|
|
@ -172,6 +172,30 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리
|
|||
설계/구현 상세는 `.claude/base/quad-types-plan.md`의
|
||||
"`type-version-check`" 절.
|
||||
|
||||
## 10. **[2026-08-20 신설, 안 막음]** Tween 초기 진입 애니메이션(`initValue`) — 에이전트 작업 범위 밖
|
||||
|
||||
`base/tween-plan.md`가 **"필요해지면 사용자가 직접 코드베이스+문서를 만진다,
|
||||
에이전트는 임의로 착수하지 말 것"**으로 확정해둔 항목인데, **여기 HUMAN_TODO에는
|
||||
그 언급이 없어서 사람 쪽 할 일 목록에서 빠져 있었다**(2026-08-20 구현 전 QA
|
||||
4라운드 `TW-16`에서 사용자가 지적 — *"틀리진 않았는데, Human todo 에 언급이
|
||||
없음"*). 지금 보강.
|
||||
|
||||
- **무엇인가**: 다이얼로그가 아래에서 위로 슬라이드-인하는 것처럼 **첫 마운트에도
|
||||
애니메이션을 원하는 경우**. 지금은 "첫 세팅은 무조건 애니메이션 없이 즉시
|
||||
스냅"(3-상태 릴레이션 슬롯의 `prev == nil` 분기)이 기본이라 이게 안 된다 —
|
||||
그 기본값은 "엔진 기본값에서 목표값으로 날아오는 진입 애니메이션 버그"를
|
||||
막으려고 일부러 넣은 것이라, 우회하려면 그 억제 동작과의 상충을 같이 설계해야
|
||||
한다.
|
||||
- **왜 에이전트가 안 하는가**(그 문서의 근거 그대로): Tween 정보가 부족한
|
||||
에이전트가 다루기엔 `hasBeenSet` 억제 동작과의 상충 판단이 미묘하고, 반대로
|
||||
Tween 자체가 다른 base 요소와 깊게 안 얽혀 있어(거의 전부
|
||||
`Handlers/Property.luau` 한 파일 + 릴레이션 슬롯) 사용자가 직접 처리하는 데
|
||||
범위상 문제가 없다.
|
||||
- **지금 상태**: 미확정("필요성 낮은 쪽으로 기움", 완전 폐기는 아님). **M0/M2를
|
||||
막지 않으므로 급하지 않고**, 실제로 진입 애니메이션이 필요해지는 시점에
|
||||
사용자가 착수하면 된다. 설계 맥락은 `base/tween-plan.md`의 "초기 진입
|
||||
애니메이션(`initValue`)" 절.
|
||||
|
||||
## 3. `.claude/question.md`의 **나머지** 항목 검토 (급하지 않음)
|
||||
|
||||
디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로
|
||||
|
|
|
|||
Loading…
Reference in a new issue