diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index ac9b375..8540e31 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -356,11 +356,20 @@ end KV 매치와 무관. **이 `NoneHandler`는 해시 파트(프로퍼티/이벤트) 전용 — 배열 파트에서 `None`을 만나는 건 완전히 다른 규칙(2026-08-07 열 번째 세션, "PreRef" - 절 "호이스팅의 실제 구현" 참고).** 배열 파트의 `None`은 "빈 슬롯" + 절 "호이스팅의 실제 구현" 참고).** 배열 파트의 `None`(`props.Ref or + None`처럼 애초에 아무것도 놓인 적 없는 자리)은 "빈 슬롯" 표시일 뿐 처리할 핸들러 자체가 없으므로, `Dispatch.drive`의 두 패스 루프 자신이 `NoneHandler`/`Dispatch.process`를 거치지 않고 바로 건너뜀 — 같은 센티널 값이지만 배열 파트냐 해시 파트냐에 따라 처리 - 경로가 다르다는 점에 유의. + 경로가 다르다는 점에 유의. **[정정, 2026-08-14 두 번째 세션] "PreRef + pre-pass가 소진시킨 자리"는 이 규칙의 예가 아님** — 그 자리는 `None`이 + 아니라 별도 센티널 `ProcessedPreRef`로 소진되고, `ProcessedPreRefHandler` + (`base/ref-plan.md`의 "PreRef" 절)를 통해 정상 `Dispatch.process` + 경로를 그대로 탐(아래 "Length/Offset" 절 참고) — 예전엔 이 둘(원래부터 + 빈 자리 vs 한때 PreRef였다가 소진된 자리)이 똑같이 `None`으로 뭉뚱그려져 + `setLength`/`setOffsetSource` 등록 책임 소재가 불분명한 갭이 있었음 + (2026-08-14 첫 번째 세션 조사에서 발견), 지금은 서로 다른 센티널로 + 명확히 분리됨. `NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지 `v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. @@ -915,12 +924,21 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) 만들어 `Frame { LayoutOrder = layoutOrder:With(offset):Compute(fn) }`처럼 써넣으면 됨 — 새 메커니즘 불필요. 상세는 `base/slot-plan.md`의 `Slot:List` 절 참고. **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에 - 참여할 게 없다는 명시적 선언. 대상은 Ref/PreRef뿐 아니라 **그 배열 + 참여할 게 없다는 명시적 선언. 대상은 일반 `Ref`뿐 아니라 **그 배열 위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로 - 캐우칭된 미전달 Ref, PreRef pre-pass가 소진시킨 슬롯 등) — `setLength`도 + 캐우칭된 미전달 Ref) — `setLength`도 같은 위치엔 짝을 맞춰 `0`으로 등록해야 함(위 `setLength` 항목의 "`nil`/`None`이면 `0`" 규칙과 항상 같이 감, 둘 중 하나만 반영되면 - 길이 합계와 실제 순서 계산이 어긋남). + 길이 합계와 실제 순서 계산이 어긋남). **[정정, 2026-08-14 두 번째 세션] + `PreRef` pre-pass가 소진시킨 슬롯은 더 이상 이 목록에 없음** — 예전엔 + 그 슬롯도 `None`으로 뭉뚱그려 등록해야 한다고만 서술돼 있었는데, + `None` 소진 슬롯은 정의상 어떤 Handler도 안 거치므로(위 "`None` 센티널" + 절) "누가 이 등록을 실제로 호출하는가"가 답 없는 갭이었음(2026-08-14 + 첫 번째 세션 조사에서 발견). 지금은 그 슬롯이 전용 센티널 + `ProcessedPreRef`로 소진되고, **`ProcessedPreRefHandler`(`base/ + ref-plan.md`의 "PreRef" 절)가 정상 매치 과정에서 직접 `setLength(0)`/ + `setOffsetSource(None)`을 등록** — "이 위치를 처음 매치한 Handler가 + 등록 책임을 진다"는 위 원칙을 특수 취급 없이 그대로 만족. **해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` → `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** @@ -949,12 +967,15 @@ lazy 생성. 원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) `nil`을 넣으면 (1) 그 자리가 "안 채워짐"과 구별이 안 되고 (2) 배열이 구멍 나면서 순수 array 취급이 깨져 접근 비용이 올라감(해시 파트로 밀림) -— `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, PreRef -pre-pass 소진 슬롯에 이미 적용된 것과 같은 원칙(`ref-plan.md`의 "PreRef" 절의 -"왜 `None`이 아니라 `nil`인가" 참고 — **단, 그 절에서 최종적으로 `nil`로 -되돌아간 건 Ref 콜백/대기자 배열 한정**이고 `sourceList`/PreRef -pre-pass처럼 순서가 실제로 중요하거나 "채워짐 여부"를 엄밀히 구별해야 -하는 배열은 여전히 `None`이 맞음, 헷갈리지 말 것). 다만 `recompute`가 +— `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, `flattened` +배열이 진짜 빈 자리(`None`, 예: `props.Ref or None`)와 PreRef pre-pass +소진 자리(`ProcessedPreRef`, 2026-08-14 두 번째 세션 이전엔 여기도 `None`) +둘 다 실재하는 센티널로 채워 구멍을 피하는 것과 같은 원칙(`ref-plan.md`의 +"Ref 일반화" 절 "왜 `None`이 아니라 `nil`인가" 참고 — **단, 그 절에서 +최종적으로 `nil`로 되돌아간 건 Ref 콜백/대기자 배열 한정**이고 +`sourceList`/`flattened`처럼 순서가 실제로 중요하거나 "채워짐 여부"를 +엄밀히 구별해야 하는 배열은 여전히 실재하는 센티널이 맞음, 헷갈리지 +말 것). 다만 `recompute`가 `1..N` 고정 범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제 자체는 안 생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐 여부 구별과 접근 비용" 쪽. diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 5f6893a..990091d 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -382,23 +382,35 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store `Dispatch.drive(inst, flattened)`는 같은 `flattened` 배열을 **두 번 순회**한다 — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며 `isPreRef(v)`인 슬롯을 찾아 그 자리에서 fire하고 즉시 **`flattened[i] - = None`**으로 소진(`nil`이 아님, 2026-08-07 열 번째 세션 정정: `nil`로 + = ProcessedPreRef`**로 소진(`nil`이 아님, 2026-08-07 열 번째 세션 정정: `nil`로 지우면 그 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 순서 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 바로 그 보장이라 치명적. **[주의, 2026-08-09 열한 번째 세션] Ref 자신의 콜백/대기자 배열은 이 이유가 적용되지 않아 `nil` 소진으로 되돌아갔음(위 "Ref 일반화" 절 참고) — 여기 PreRef pre-pass는 순서 - 보장이 실제로 필요한 별개 케이스라 `None` 소진이 계속 맞음, 두 - 사례를 혼동하지 말 것**). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 - **같은 테이블**을 다시 순회 — 이때 `None`으로 소진된 슬롯은 **정상 - `Dispatch.process`/`NoneHandler` 경로를 안 타고 두 패스 루프 자신이 - 직접 건너뜀**(`if v == None then continue end`, 배열 파트 전용 - 특수 취급). **해시 파트의 `None`(Modifier 필드 명시적 지우기)과는 - 의미가 다름** — 해시 파트 `None`은 "이 프로퍼티 핸들러야, 방금 - 지워진 걸 알고 알아서 처리해"라는 의미 있는 재디스패치(`NoneHandler` - 경유)지만, 배열 파트 `None`은 그냥 "여기 원래 아무것도 없었던 것과 - 같다"는 순수 빈 슬롯 표시라 처리할 핸들러 자체가 없음 — 재디스패치 - 없이 루프가 직접 스킵하는 게 맞음. "호이스팅"은 PreRef를 배열의 맨 + 보장이 실제로 필요한 별개 케이스라 실재하는 센티널 소진이 계속 맞음, 두 + 사례를 혼동하지 말 것**). **[정정, 2026-08-14 두 번째 세션] 소진 값은 + `None`이 아니라 전용 센티널 `ProcessedPreRef`(단일 `{}`, `None`과 같은 + 급의 유니크 키 — 사용자 제안).** 옛 설계는 소진 값을 `None`으로 뭉뚱그려 + "그 자리가 원래부터 빈 자리"였던 경우(`props.Ref or None`)와 "한때 + PreRef였다가 방금 fire되어 소진된 자리"를 구별 못 했고, 그 결과 + 아래 "Length/Offset" 계약(`base/dispatch-core-plan.md`, 2026-08-09 + 여섯 번째 세션 확정)이 "이 위치를 처음 매치한 Handler가 + `setLength`/`setOffsetSource` 등록 책임을 진다"고 못박아 놨는데도 + `None` 소진 슬롯은 정의상 어떤 Handler도 안 거쳐서(아래 참고) "그럼 + 누가 그 등록을 실제로 호출하는가"가 문서 어디에도 없는 갭이었음 + (2026-08-14 첫 번째 세션 조사에서 발견). `ProcessedPreRef`로 소진처를 + 분리하면 이 갭이 구조적으로 사라짐 — 아래 `ProcessedPreRefHandler` + 참고. (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 + **같은 테이블**을 다시 순회 — 이때 `ProcessedPreRef`로 소진된 슬롯은 + **정상 `Dispatch.process` 경로를 그대로 탄다**(아래 + `ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 두 패스 + 루프 자신이 `if v == None then continue end`로 직접 건너뛰고 어떤 + Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset + 등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). 진짜로 + 원래부터 빈 자리인 `None`(`props.Ref or None` 등)은 여전히 두 패스 + 루프가 직접 건너뜀 — 두 센티널이 이제 서로 다른 경로를 타므로 + 혼동 금지. "호이스팅"은 PreRef를 배열의 맨 앞으로 물리적으로 옮기는 게 아니라, **PreRef 전용 선행 루프가 통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻** — 그래서 소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저 @@ -408,7 +420,31 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 두 번째(정상) 패스가 이미 정당하게 처리된 그 PreRef를 `Dispatch.process`로 다시 넘기게 되고, 그러면 이 가드 Handler가 엉뚱하게 매치되어 **정상적인 PreRef 사용에도 에러가 터짐** — 소진은 - 이 오탐을 막기 위해 반드시 필요. + 이 오탐을 막기 위해 반드시 필요(`ProcessedPreRef`는 `isPreRef(v)`가 + 거짓이라 이 가드 Handler와는 애초에 안 겹침). + - **`ProcessedPreRefHandler` — 소진된 슬롯이 Length/Offset에 "0 기여"를 + 등록하는 전담 Handler (2026-08-14 두 번째 세션, 사용자 제안 — 위 갭의 + 해소).** + ```lua + ProcessedPreRefHandler.priority = <매우 높음, NoneHandler와 동급> + ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef) + function ProcessedPreRefHandler.process(inst, i, v) + Dispatch.setLength(inst, i, 0) + Dispatch.setOffsetSource(inst, i, None) + return function() end -- no-op retract, 이 자리는 fire가 끝나 + -- 되돌릴 상태 자체가 없음 + end + ``` + `isHandlable`이 `v == ProcessedPreRef`만 잡으므로 배열 파트 전용(해시 + 파트엔 이 센티널이 등장할 경로 자체가 없음). 이걸로 `base/ + dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 "이 + 위치를 처음 매치한 Handler가 등록 책임을 진다"는 계약을 특수 취급 + 없이 그대로 만족시킴 — 매치되는 Handler 자신이 곧 등록자라 "누가 + 등록하는가"라는 질문 자체가 안 생김. 반환하는 retract는 하드코딩된 + no-op인데, 이건 "PreRef는 취소 개념이 없다" 절(아래)이 말하는 것과 + 같은 이유 — fire가 이미 실행한 부작용은 되돌릴 수 없으므로 이 자리가 + dispatch 체인에 실제로 올라가 있어도(**[정정] 예전 서술과 달리 이제는 + 올라가 있음** — 아래 참고) retract가 할 일이 없는 것뿐. - **명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변) — `NoneHandler.isHandlable(inst,k,v) = (v == None)`은 `k` 타입을 전혀 안 가리므로 숫자 키(`k=number`)에서도 이론상 매치될 수 있어 보이지만, @@ -426,7 +462,8 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 번째 세션). 같은 세션에서 사용자가 직접 `{[1]=1,[2222]=2222, [211]=211,...}`류 **키가 듬성듬성한(sparse)** 테이블을 REPL로 실측해, 그런 테이블은 순회 순서가 index 오름차순이 전혀 아님(해시 - 버킷 순서)을 확인함 — 그래서 위 pre-pass는 (nil이 아니라) `None`으로 + 버킷 순서)을 확인함 — 그래서 위 pre-pass는 (nil이 아니라) 실재하는 + 센티널(`ProcessedPreRef`, 2026-08-14 두 번째 세션 이전엔 `None`)로 소진해 테이블을 "구멍 없이 촘촘한" 상태로 계속 유지하는 전략으로 이 위험을 원천 회피함(검증 불필요, 애초에 구멍을 안 만드므로). **여전히 M0에서 검증해야 하는 건 다른 케이스**: `props.Modifier`/ @@ -468,20 +505,27 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store "한 값 종류만 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. 이 Handler는 **`Dispatch.process`/`getHandler`의 정상 우선순위 스캔에 등록**되는 반면(pre-pass처럼 그 밖에서 도는 게 아님), 리터럴 배열의 - `PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(`None` 처리, - `nil` 아님)해 정상 두 패스 스캔에 다시 노출되지 않게 하므로, 이 - Handler가 실제로 + `PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(**[정정, + 2026-08-14 두 번째 세션] `None`이 아니라 `ProcessedPreRef` 처리**, + 위 "호이스팅의 실제 구현" 절)해 **이 가드 Handler(`isPreRef(v)`만 + 매치)에는 다시 노출되지 않게** 하므로(정상 두 패스 스캔 자체엔 + `ProcessedPreRefHandler`를 통해 여전히 노출됨 — "스캔에 안 걸림"이 + 아니라 "이 가드에 안 걸림"이 정확한 설명), 이 Handler가 실제로 매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로 새어들어온" 버그 케이스뿐 — 그래서 no-op이 아니라 즉시 `error`. - **PreRef는 "취소"라는 개념이 없다 — 1회용, 재사용은 즉시 error (2026-08-12 여섯 번째 세션, 사용자 제안 채택).** `Ref`가 "다른 값으로 교체되면 `retract`로 취소됨"이라는 의미의 취소를 가질 수 있는 건 정상 우선순위 스캔의 `(inst,k)` 디스패치 체인에 실제로 참여해서임 — - `Dispatch.retractFrom`이 그 체인을 대상으로 동작함. `PreRef`는 애초에 - 그 체인에 올라간 적이 없음(pre-pass에서 fire와 동시에 `None`으로 - 소진되고 정상 두 패스는 건드리지 않음, 위 "호이스팅의 실제 구현" 절) — - 그래서 "취소 가능 여부" 자체가 성립할 토대가 없었던 게 구조적으로 - 이미 사실이었음, 이번 세션은 그걸 명문화한 것뿐. 진짜 위험은 취소가 + `Dispatch.retractFrom`이 그 체인을 대상으로 동작함. **[정정, + 2026-08-14 두 번째 세션] "그 체인에 올라간 적이 없다"는 근거는 더 이상 + 정확하지 않음** — `ProcessedPreRefHandler` 신설로 소진된 슬롯도 이제 + 정상 `Dispatch.process` 경로를 타 체인에 올라감(위 "호이스팅의 실제 + 구현" 절). "취소 개념이 없다"는 결론 자체는 그대로 유효하지만 이유가 + 바뀜: 체인에 없어서가 아니라, **그 자리의 retract가 하드코딩된 + no-op이기 때문** — PreRef의 fire는 `fn(inst)`를 실제로 실행하는 + 부작용이라 애초에 "되돌릴 상태"가 없고, 그래서 체인에 올라가 있어도 + retract가 할 일 자체가 없음. 진짜 위험은 취소가 아니라 **재사용**: 이미 한 번 fire된 `PreRef` 객체를 두 번째 construction의 children 배열에 다시 놓으면, 거기서 등록하는 `:Callback(fn)`이 "이미 채워져 있으면 즉시 1회 호출"이라는 규칙(위 diff --git a/.claude/luau-test/done/02-none-sentinel-vs-nil-holes.luau b/.claude/luau-test/done/02-none-sentinel-vs-nil-holes.luau index b812c4c..ab621be 100644 --- a/.claude/luau-test/done/02-none-sentinel-vs-nil-holes.luau +++ b/.claude/luau-test/done/02-none-sentinel-vs-nil-holes.luau @@ -13,8 +13,14 @@ 문제를 풀고 있었다는 것: - **순서가 중요한 배열(PreRef pre-pass 소진 슬롯, Length/Offset의 - sourceList)**: 계속 `None` — 구멍이 생기면 해시 파트로 밀려 - index 순회 순서가 깨지므로, "채워짐"을 유지해야 함. + sourceList)**: 계속 실재하는 센티널(구멍이 아닌 non-nil 값) — 구멍이 + 생기면 해시 파트로 밀려 index 순회 순서가 깨지므로, "채워짐"을 + 유지해야 함. 이 스크립트는 그 센티널을 `None`으로 대표해 검증하지만, + **[2026-08-14 두 번째 세션] PreRef pre-pass 소진 슬롯의 실제 값은 + 이후 `None`에서 전용 센티널 `ProcessedPreRef`로 갈렸음**(`base/ + ref-plan.md` "PreRef" 절) — 여기서 검증하는 성질(실재하는 non-nil + 값이면 순서/`#t`가 안 깨진다) 자체는 어느 센티널을 쓰든 동일해서 + 이 결과는 계속 유효, 재실행 불필요. - **순서가 안 중요하고 슬롯 재사용이 필요한 배열(Ref 콜백/대기자 리스트)**: `nil` + "빈 슬롯을 선형 탐색해 재사용"하는 등록 함수 (`table.insert`는 안 씀 — 구멍 있는 테이블에서 `#t`가 미정의 diff --git a/.claude/research/lifecycle-hooks-plan.md b/.claude/research/lifecycle-hooks-plan.md index 868566b..f6182f4 100644 --- a/.claude/research/lifecycle-hooks-plan.md +++ b/.claude/research/lifecycle-hooks-plan.md @@ -153,32 +153,60 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를 `OnDestroyed`와 달리 **진짜로 공짜가 아님** — 그래서 지금 채택하지 않기로 함. -**`PostRef` 스케치(사용자 제안, 2026-08-14)** — 완전히 새로운 -메커니즘을 발명할 필요는 없어 보임. `PreRef`의 pre-pass가 "두 패스 -루프를 돌기 **전에** 배열 파트를 미리 한 번 훑어 fire하고 소진하는" -별도 선행 스캔이었던 것(`base/ref-plan.md` "PreRef" 절, "호이스팅의 -실제 구현" 항목)과 **정확히 대칭인 후행 스캔**을 만들면 됨 — 두 패스가 -끝난 **뒤에** 배열 파트를 다시 한번 훑어 `PostRef` 슬롯만 골라 fire. -`PreRef`가 이미 갖고 있는 장치(호이스팅 없이 그냥 후행이면 되므로 오히려 -더 단순할 수 있음, 1회용 `_fired` 가드, `None` 소진 대신 이 시점엔 -순서 보장이 더 이상 필요 없으니 `nil` 소진도 검토 가능)를 그대로 -거울상으로 재사용하는 구현이라 **새 개념이 아니라 기존 `PreRef` 코드의 -변형**에 가까움 — 다만 `Dispatch.drive`에 실제 루프 한 번이 추가되는 -비용은 여전히 있으므로 "공짜"까지는 아님. `OnRendered(fn)`은 -`PostRef():Callback(fn)`을 반환하는 팩토리로, 위 `OnCreated`와 완전히 -같은 패턴이 됨. +**`PostRef` 스케치(사용자 제안, 2026-08-14, 두 번째 세션에 `ProcessedPreRef` +선례 반영해 갱신, 같은 세션 후속 제안으로 다시 갱신 — "후행 스캔" 초안 +폐기)** — 완전히 새로운 메커니즘을 발명할 필요는 없어 보임. **핵심 통찰 +(사용자 제안): `PreRef`의 pre-pass가 이미 배열 파트 전체를 index 순서로 +한 번 훑고 있으니, 같은 스윕에서 `isPostRef(v)`도 같이 잡아내면 되고 +`PostRef` 전용 후행 재순회(두 번째 전체 `for`)는 아예 필요 없음.** + +- **pre-pass 한 번으로 `PreRef`/`PostRef` 둘 다 처리**: 같은 루프 안에서 + `isPreRef(v)`면 기존 그대로 그 자리에서 즉시 fire하고 + `flattened[i] = ProcessedPreRef`로 소진. `isPostRef(v)`면 **아직 + fire하지 않고**, 이 `Dispatch.drive(inst, flattened)` 호출 하나에만 + 로컬인 평범한 배열 `postRefList`(`Relate` 같은 별도 저장소 불필요 — + 이 함수 콜스택 안에서만 살면 됨)에 그 인스턴스를 순서대로 push하고 + 즉시 `flattened[i] = ProcessedPostRef`로 소진(1회용 재사용 가드 + `_fired`도 이 시점에 세팅 — "슬롯이 소진되는 시점"과 "재사용 방지가 + 걸리는 시점"을 `PreRef`와 동일하게 맞춤, 실제 콜백 fire와 시점이 + 갈리는 건 아래 항목뿐). +- **`ProcessedPostRefHandler`는 `ProcessedPreRefHandler`와 완전히 + 대칭**: 정상 두 패스가 `ProcessedPostRef`를 매치해 + `setLength(0)`/`setOffsetSource(None)`을 등록하고 no-op retract를 + 반환 — 새 비대칭 규칙이 필요 없음. **[정정] 이전 초안은 "PostRef는 + 소진 전 원본 값이 정상 두 패스의 매치 대상이어야 한다"고 + 잘못 짚었었는데, pre-pass에서 미리 소진해두면 그 비대칭 자체가 안 + 생김** — `PreRef`의 "동적 경로 가드" Handler(정상 스캔에서 + `isPreRef(v)`를 잡아 즉시 error)와 짝이 되는 `PostRef`용 가드 Handler도 + 똑같이 필요(pre-pass가 놓쳤을 때만 매치되는 버그 케이스 전용, `error`). +- **두 패스가 끝난 뒤, `Dispatch.drive`가 `postRefList`를 그 순서 그대로 + 순회하며 각 `PostRef`를 fire** — 별도 후행 전체 재순회가 필요 없음, + pre-pass가 이미 만들어둔 목록을 그대로 소비하면 끝. 복수 `PostRef` + 간 순서는 복수 `PreRef`와 같은 원칙(배열 index 순서 그대로)이 자연히 + 적용됨. +- 결과적으로 `PreRef`와 `PostRef`는 **소진 메커니즘이 완전히 대칭** + (둘 다 pre-pass에서 즉시 `Processed*` 센티널로 소진, 둘 다 전담 + `Processed*Handler`가 Length/Offset을 등록) — 유일한 차이는 "실제 + 콜백을 언제 부르는가"(`PreRef`는 pre-pass 그 자리, `PostRef`는 두 + 패스가 다 끝난 뒤 `postRefList` 순회) 하나뿐. `_fired` 1회용 가드도 + 거울상 그대로 재사용. 비용도 애초 우려("루프 한 번이 추가되는 비용")보다 + 작음 — 추가되는 건 전체 배열 재순회가 아니라 `postRefList`(실제 + `PostRef` 개수만큼)의 순회뿐이라, "공짜"는 아니어도 이전 "후행 스캔" + 초안보다 훨씬 저렴. `OnRendered(fn)`은 `PostRef():Callback(fn)`을 + 반환하는 팩토리로, 위 `OnCreated`와 완전히 같은 패턴이 됨. **스코프도 여전히 불명확함**: "렌더 완료"가 (a) 이 인스턴스 자신의 프로퍼티/이벤트 세팅만 끝나면 되는지, (b) 이 인스턴스의 **자식들까지 전부 마운트를 끝내야** 하는지 — React류 `on*Rendered` 이름들은 보통 (b)(서브트리 전체 완료)를 뜻하는 경우가 많아, 이름만 보고 (a)로 기대하는 사람과 실제 구현이 (b)라면(또는 반대라면) 기대치가 어긋날 위험이 있음. -`PostRef` 후행 스캔은 자연스럽게 (a)만 줌 — (b)를 원하면 자식 서브트리 -전체의 마운트 완료를 기다리는 별도 신호가 있어야 해서 훨씬 큰 작업. +`PostRef`의 `postRefList` 소비(위 스케치)는 자연스럽게 (a)만 줌 — (b)를 +원하면 자식 서브트리 전체의 마운트 완료를 기다리는 별도 신호가 있어야 +해서 훨씬 큰 작업. **착수 시점에 판단할 선택지(지금은 고르지 않음)**: -- (a) 위 `PostRef` 스케치대로 진짜 post-pass를 만든다 — (a) 스코프 - (자기 자신 세팅 완료)의 정확한 보장. +- (a) 위 `PostRef` 스케치대로 두 패스 뒤 `postRefList`를 소비한다 — + (a) 스코프(자기 자신 세팅 완료)의 정확한 보장. - (b) 새 메커니즘 없이 일반 `Ref`로 근사한다 — Store를 통해 늦게 도착하는 값으로 "대충 렌더 이후"를 흉내내되, "완전한 보장은 없음"을 문서에 명시하는 선에서 타협. diff --git a/ROADMAP.md b/ROADMAP.md index 323304f..cf6343a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,11 +44,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증 (2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절) — **PreRef pre-pass의 소진은 - `nil`이 아니라 `None`으로(2026-08-07 열 번째 세션 정정, 사용자가 + `nil`이 아니라 실재하는 센티널로(2026-08-07 열 번째 세션 정정, 사용자가 Luau REPL로 반례 제시 — 키가 듬성듬성해지면 순회가 index 순서를 전혀 안 지킴), 이 경로는 nil-hole 위험이 아예 없도록 설계됐으므로 "구멍 있는 테이블 순회" 자체를 검증할 필요는 없어짐(같은 절 "왜 - `nil`이 아니라 `None`인가" 참고)** + `nil`이 아니라 `None`인가" 참고). **[정정, 2026-08-14 두 번째 세션] + 소진 값은 이제 `None`이 아니라 전용 센티널 `ProcessedPreRef`** — + 정상 두 패스가 그 자리를 `ProcessedPreRefHandler`로 매치해 + `Dispatch.setLength(0)`/`setOffsetSource(None)`을 등록하도록 재설계됨 + (`base/ref-plan.md` "PreRef" 절, `base/dispatch-core-plan.md` + "Length/Offset" 절) — 아래 `PreRef` pre-pass/동적 경로 가드 + 체크리스트 항목도 이 값으로 스파이크할 것. - [ ] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성, `export type Params = {...}`로 타입 체크되는지 확인 (`component-composition-plan.md` 최종 결론 1번) — **`props.Modifier or @@ -536,14 +542,22 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `PreRef` 항목만 fire(Dispatch.process/getHandler 우회하는 raw 루프, `flatten` 함수에는 얹지 않음 — 재바인드 시 flatten 재호출 가능성과 충돌하므로 기각). 복수 `PreRef`는 배열 index 순서 그대로(별도 규칙 - 없음). fire된 슬롯은 그 자리에서 소진(`None` 처리, `nil` 아님 — - 2026-08-07 열 번째 세션 정정)해 이어지는 정상 두 패스에 다시 노출 - 안 되게 함 — `base/ref-plan.md` "PreRef" 절 + 없음). fire된 슬롯은 그 자리에서 소진(**[정정, 2026-08-14 두 번째 + 세션] `None`이 아니라 전용 센티널 `ProcessedPreRef` 처리** — 아래 + `ProcessedPreRefHandler` 항목이 그 자리를 정상 두 패스로 마저 처리) + — `base/ref-plan.md` "PreRef" 절 - [ ] `PreRef` 동적 경로 가드 Handler — `{isHandlable = v is PreRef, process = error(...)}` 형태로 정상 우선순위 레지스트리에 등록, `NoneHandler`와 같은 "한 값 종류 전담" 패턴. 리터럴 배열 경로는 pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을 우회한 버그라는 뜻 — 같은 절 참고 +- [ ] **[2026-08-14 두 번째 세션 신설]** `ProcessedPreRefHandler` — + `{isHandlable = v == ProcessedPreRef, process = setLength(0)+ + setOffsetSource(None)+no-op retract}` 형태로 정상 우선순위 + 레지스트리에 등록, `NoneHandler`와 같은 "한 값 종류 전담" 패턴. + PreRef pre-pass가 소진시킨 자리가 Length/Offset에 "0 기여"를 등록할 + 책임을 지는 자리 — `base/ref-plan.md` "PreRef" 절, `base/ + dispatch-core-plan.md` "Length/Offset" 절 - [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면 `coroutine.resume(v, self)`+`nil`로 소진(2026-08-09 열한 번째 세션 최종 정정 — 순서 안 중요 + 슬롯 재사용 위해 `None`이 아닌