콘텐츠로 이동

3.2.0 이후의 모든 버전

3.2.0

Changed

  • BREAKING — q.D가 q.Declaration으로 이름이 바뀌었습니다. 자동완성에서 q. 뒤에 뜻이 보이고, 한 글자 이름이라 코드 검색에 안 잡히던 문제가 사라집니다. 옮기는 법: 파일마다 받는 별칭 한 줄만 고치면 되고(local D = q.Declaration), 본문의 D.Frame {…} 표기는 그대로입니다 — 별칭 이름은 여러분이 원하는 대로 지어도 됩니다. 옛 q.D는 남기지 않았습니다.
  • BREAKING — 타입 이름 셋도 같이 바뀌었습니다: D → Declaration, DMapper → DeclarationMapper, DModifier → DeclarationModifier. 클래스별 타입이 사는 생성 모듈의 하위 경로도 D → Declaration입니다(require로 그 모듈을 직접 가져다 쓰는 경우에만 해당).

3.3.0

Added

  • q.debug가 켜져 있으면 배열 중간의 nil 구멍에서 멈춥니다 — slot:List의 data(Quad0269, 재조정이 원소를 파괴하기 전에)와 q.Slot({ … })의 initial(Quad0270). 꺼져 있으면 지금처럼 검사하지 않습니다(목록이 구멍에서 끝나고 :List는 구멍 뒤 항목의 원소를 파괴하는 정의되지 않은 동작). 끝자리의 nil은 구멍이 아닙니다.

  • slot:Lazy(fn) — 빈 Slot에 처음 꽂혀 마운트될 때 한 번만 내용을 짓는 함수를 겁니다. fn이 돌려준 값 하나(원소·State·여러 원소를 담은 Slot, 또는 nil)가 내용이 되고, 뗐다 다시 꽂아도 다시 짓지 않습니다. 컴포넌트가 자기 본문에서 Context 등을 채운 뒤에 자식을 짓게 할 때 씁니다 — 받는 컴포넌트는 평범한 Slot으로 받으면 됩니다. 설치 규칙과 수동 CRUD 잠금은 :List/:Single과 같고(그 에러 문구들이 :Lazy도 함께 말하게 넓어졌습니다), fn이 함수가 아니면 Quad0271, 값을 둘 이상 돌려주면 Quad0272.

  • slot:WhenIdle(fn) — fn(slot)을 그 Slot이 연산 한가운데가 아니면 바로, 한가운데면 그 연산이 끝난 직후에 실행합니다. 원소를 붙이거나 떼는 엔진 호출·마운트·파괴 안에서 불린 콜백(q.OnDestroyed, q.OnChange("Parent", …), 마운트 중 발화한 Observer)이 그 Slot을 고쳐야 할 때의 문입니다(직접 CRUD는 Quad0167). 신호 설정(Immediate·Deferred)과 테스트 mock에서 같은 코드가 되고 엔진 스케줄러를 쓰지 않습니다 — 바깥 연산이 반환할 때는 fn이 이미 실행된 뒤입니다. index는 fn 안에서 다시 구하세요. 파괴된 Slot에 건 요청은 그대로 실행됩니다. fn이 함수가 아니면 Quad0268.

  • q.Tween{…}(그리고 q.Animate{…})에 콜백 셋 Started/Completed/Cancelled가 생겼습니다 — (inst, prop, value)(그 트윈이 도는 인스턴스·프로퍼티 이름·목표 값)를 받고, 인자 없는 함수도 됩니다. 다음 트윈을 잇는 자리는 Completed입니다 — 콜백은 그 자리의 기록을 마친 뒤 불리므로 Completed 안에서 같은 원천을 :Set하면 스냅(모션 끔·Time = 0)이어도 그 쓰기가 마지막으로 남습니다. 다른 콜백(Started·Cancelled) 안에서 같은 원천을 되쓰는 것과, 끝나지 않는 Completed 연쇄(모션을 끄면 동기 재귀로 스택이 넘칩니다)는 정의되지 않습니다 — 계속 도는 애니메이션은 RepeatCount = -1, Reverses = true로. Completed는 quad가 그 자리를 살려 둔 채 트윈이 목표에 닿았을 때 한 번 불립니다. Started는 트윈을 재생한 직후(DelayTime이 있으면 값이 움직이기 전), Completed는 자연히 끝났을 때만, Cancelled는 새 값이나 철거가 돌고 있던 트윈을 끊을 때 불립니다(순서는 이전 Cancelled → 새 Started → 새 Completed; 교체 때 옛 트윈이 목표에 닿고 통지만 남았으면 이전 Completed, 자리 철거 때는 통지만 남은 트윈도 Cancelled). 같은 Tween을 프로퍼티 여럿에 걸면 자리마다 한 번씩 불리고, 숏핸드 키(UICorner/UIPadding/UIPaddingOffset/UIScale)는 값 하나에 한 번 (숏핸드를 건 인스턴스, 숏핸드 키, 준 값)으로 불립니다. 첫 세팅처럼 스냅되는 자리에서는 Started·Completed가 바로 이어서 불리고, CanAnimate = false여도 콜백이 있으면 그 둘은 불립니다. 트윈이 도는 동안 인스턴스가 파괴되면(파괴는 자리별 되돌리기를 거치지 않으므로 트윈은 끝까지 돕니다) 그 뒤의 Completed는 불리지 않습니다(파괴된 인스턴스의 자리를 직접 되돌려도 콜백은 불리지 않습니다). 콜백 셋의 정의(언제·몇 번·어떤 순서로 불리는지)는 4.0.0 전까지 다듬어질 수 있습니다.

  • tween:Wrapped(wrap)와 tween:Mapped(fn, wrap) — 값을 그대로 두거나 바꾸면서 콜백 셋만 다시 정한 사본을 만듭니다. wrap은 원본 콜백 셋을 보는 핸들(cb.Started 등, cb:Map(g))을 받아 새 셋을 돌려줍니다 — 같은 Tween을 여러 자리에 쓸 때 거르거나 합치는 자리입니다.

  • 모든 quad 에러 메시지가 QuadNNNN 식별자로 시작합니다(Luau의 파일:줄: 접두 바로 뒤, 예: Quad0145 Slot:List: duplicate key a). 한 번 붙은 번호는 다른 뜻으로 재사용하지 않으므로 검색·분기는 번호로 하세요(문구는 여전히 API가 아닙니다). 다만 어떤 실수가 어느 번호로 보고되는지는 4.0.0 전까지 다듬어질 수 있습니다(같은 실수를 여러 자리가 다른 번호로 알리는 묶음을 맞추는 중) — 4.0.0 전에는 올릴 때 이 기록을 확인하세요. 번호마다 설명은 레퍼런스 › 에러 코드. 엔진(Roblox)이 내는 에러에는 번호가 없습니다.

  • q.Out(name, src) — 엔진이 바꾼 프로퍼티 값을 Source에 되쓰는 슈거(q.OnChange 위, 같은 디스크립터). D.TextBox { Text = text, q.Out("Text", text) }처럼 내려가는 문자 키 옆에 나란히 둡니다. 새 값이 src:Get()과 같으면 :Set하지 않습니다(첫 쓰기의 메아리 방지). :Compute 결과 같은 읽기 전용 State는 타입에서 거부됩니다.

  • source:SetIfChanged(v) — 값이 현재 값과 ==로 같으면 아무것도 하지 않고(리비전도 전파도 없음), 다르면 :Set(v)와 똑같은 쓰기입니다. 비교는 Luau == 그대로라 두 값이 같은 __eq 메타메서드를 가지면 그것이 같음을 정하고, NaN은 늘 씁니다. :Set처럼 자기 자신을 돌려줍니다. :Set은 그대로 같은 값에도 늘 전파합니다 — 이 메서드는 되먹임 고리를 끊어야 하는 자리(Effect가 자기 의존성을 다듬어 되쓸 때, 엔진 신호를 Source로 올릴 때)를 위한 것입니다. Store 필드에서도 그대로 씁니다.

  • source:SetAndDispose(v) — :Set(v)을 한 뒤 바뀌기 전 값을 q.dispose로 파괴합니다. 전파가 먼저 끝나 그 값을 앉혀 두었던 자리가 비워진 뒤에 파괴하므로, Get()으로 옛 값을 잡아 두고 Set 뒤 dispose하던 세 줄이 한 줄이 됩니다. 옛 값이 nil·q.None이거나 방금 넣은 값과 같은 참조이면 파괴하지 않습니다. 그 밖의 옛 값에서 dispose가 던지는 에러(파괴할 수 없는 값, 아직 다른 자리가 쥔 값)는 쓰기가 끝난 뒤에 그대로 납니다. :Set처럼 자기 자신을 돌려줍니다.

  • Effect의 cleanup이 인자 하나 dying: boolean을 받습니다 — 묶인 인스턴스가 죽어서 소진될 때만 true, 다음 fn 실행 직전·:Unsubscribe()·숫자 키 자리를 떠날 때는 false. 인자를 안 받던 cleanup은 그대로 됩니다 — 단 BREAKING(타입만): cleanup 타입이 () -> ()에서 (dying: boolean) -> ()로 바뀌어 local cleanup: () -> () = …처럼 옛 타입을 명시해 돌려주면 strict 에러입니다(인라인 리터럴·추론된 로컬 함수는 그대로 통과). 옮기는 법: 주석을 (dying: boolean) -> ()로 바꾸거나 빼세요. 이걸로 q.OnDestroyed가 고쳐졌습니다(아래 Fixed).

  • q.Bookkeeping.claimOwnerAt(element, inst, k) / q.Bookkeeping.releaseOwner(element, ownerKey) — 숫자 키 자리에 값을 놓는 핸들러가 “이 값은 이 자리에 앉아 있다”를 등록·해제하는 소유권 op. Slot이 원소에 쓰던 그 레지스트리 하나를 정적 자식·숏핸드 관리 자식과 같이 씁니다. 자기 핸들러가 자식을 부모에 붙인다면 이 둘을 불러야 아래 단일 마운트 규칙에 들어갑니다.

  • 백엔드 계약 op isClaimed(inst) — “이 요소가 이미 quad 소유인가”. Slot이 원소를 받을 때와 정적 자식 자리마다(구동 hot path) 부릅니다. BREAKING(프로바이더 작성자) — 자기 백엔드를 만든 쪽은 이 op를 설치해야 합니다(quad-roblox는 claim 셋업 유무; 테스트 mock은 Instance.new가 태어날 때 claim된 것, 밖의 것은 Instance.foreign). 뜻은 “살아 있는 동안 quad 손에 닿았고 지금도 살아 있는가” — quad 제거 경로로 죽은 요소는 즉시 거짓.

  • 앱 코드가 써도 되는 엔진 경계 술어 둘을 레퍼런스에 소개: q.Backend.isClaimed(inst)(quad가 만든 인스턴스가 아직 살아 있는가)와 q.Backend.canExecute(self)(Effect 콜백이 남긴 비동기 콜백 안에서 “지금 돌 수 있나” — 새 이름은 없습니다).

  • quad_roblox 패키지 루트가 프로퍼티 값 타입 Field<T>(setter가 받는 값)와 FieldOut<T>(변환 함수의 old·Peek이 돌려주는 저장된 값)를 내보냅니다. 전에는 생성 모듈 경로로만 닿았는데, pesde 설치에서는 그 경로가 드러나지 않았습니다. 클래스별 타입도 같은 루트에서 내보냅니다 — <Class>Modifier(상위 클래스 포함), Into<Class>, <Class>Elem. how-to의 require(<생성 모듈 경로>) 자리표시자는 이 경로로 바뀌었습니다. 클래스별 타입의 모양은 4.0.0 전까지 바뀔 수 있습니다 — 클래스마다 복제한 이름 대신 타입 함수로 조립하는 쪽을 검토하고 있습니다(Field/FieldOut은 해당 없음).

  • q.moduleIdentity — 모듈 인스턴스마다 하나인 빈 identity 토큰. 백엔드·플러그인이 자기 값에 붙여 두고 “이 인스턴스가 만든 값인가”를 비교하는 데 씁니다(quad-base가 아래 인스턴스 교차 검사에 같은 방법을 씁니다).

  • q.debug가 켜져 있을 때 q:AddPlugin이 모듈의 기존 필드를 덮어쓰면 설치 시점에 한 줄을 출력합니다. 덮어쓰기 자체는 그대로 허용됩니다(코어 부품을 일부러 갈아 끼우는 플러그인용). UseProvider는 해당 없음.

  • q.debug가 켜져 있으면 옵션 테이블의 모르는 키(오타·옛 이름)를 한 줄로 알립니다 — q.Debounce/q.Throttle, slot:List/slot:Single의 opts, q.Tween, q.Animate. 경고만 하고 실행은 계속합니다(모르는 키는 전처럼 무시).

  • q.debug가 켜져 있으면 Claim이 두 가지 실수를 알려 줍니다: 루트 디스크립터에 문자열 키를 준 경우(M.Frame("Name") — 키는 무시되고 루트 자신이 claim됩니다)와 자식 자리의 키가 문자열이 아닌 경우(M.Root를 자식에). 동작은 그대로입니다(런타임 검사를 두지 않는 Claim의 규칙).

  • q.debug가 켜져 있으면 비용이 드는 검사 하나가 더 돕니다 — 평소 경로에서 빼 둔 대신 켜 둔 개발 중에는 에러로 멈춥니다: 디스패치 핸들러의 process가 함수가 아닌 retractor(true·숫자·테이블)를 돌려주면 그 자리에서 Quad0077(꺼져 있으면 지금처럼 nil만 검사하고, 그런 값은 나중에 그 자리를 무를 때 번호 없는 에러로 죽습니다). Quad0077의 문구는 … must return a retractor function … (got {type})으로 바뀌었습니다.

  • 디스패치 핸들러 계약에 선택 필드 debugOnly: boolean? — true면 그 핸들러는 q.debug가 켜져 있을 때만 후보가 됩니다(꺼져 있으면 에러 없이 후보에서 빠짐; q.debug는 스캔마다 읽으므로 켜고 끄면 바로 반영). 진단 필터(경고를 찍고 false를 돌려 스캔을 그대로 흘려보냄)나 개발 중에만 키를 맡는 핸들러에 씁니다. 꺼 둔 비용은 분기 하나이고, 등록해도 다른 핸들러끼리의 순서는 바뀌지 않습니다. true/false/nil이 아니면 등록 때 Quad0266. q.Dispatch.listHandlers()는 이 핸들러도 늘 돌려줍니다.

  • q.debug가 켜져 있으면 경고가 더 나옵니다(한 줄씩, 동작은 그대로 — 목록의 소스는 레퍼런스 core/01 q.debug): 배열 원소로만 쓰는 quad 값(Ref 가족·Observer·Effect·Slot·Tag·Attr)이 props의 문자 키에 놓였을 때(반영 프로퍼티·이벤트 이름이면 그 값의 전용 거절 대신 프로퍼티·이벤트로 들어가던 자리 — 같은 요소·키는 한 번), :Compute 계산 함수가 도는 도중 Source:Set/:Emit이 불렸을 때(같은 Source는 한 번), 같은 노드의 앞선 계산이 다른 코루틴에서 멈춘 채(계산 함수 안 yield) 새 계산이 시작될 때, state:Observer(fn)의 콜백이 도는 도중(첫 발화 포함) 같은 핸들에 변경이 다시 도착했을 때(콜백이 자기 상류를 되먹였다는 뜻 — 핸들마다 한 번; props 자리에 놓인 State의 값을 처리하는 동안 불린 콜백이 그 State를 되쓴 경우도 포함 — 단 정의된 경로 둘은 빠집니다: q.Out·OnChange 엔진 왕복(신호가 즉시 배달돼 처리 도중 동기로 돌아와도)과 Tween의 Completed에서 다음 트윈을 잇는 것(스냅이라 동기로 불려도) — 이 둘은 그 State의 처리만 빼 주므로, 그 State를 :Set한 바깥의 사용자 Observer가 이 연쇄로 자기 상류를 되먹이면 알립니다; quad가 자리 계산·목록에 내부로 쓰는 Observer와, Slot의 Offset/Length를 보는 콜백이 Slot을 CRUD해 그 값이 다시 바뀌는 고리(마운트·다시 앉히기 포함)는 빠집니다 — 그 고리는 Slot이 스스로 다시 계산해 맞추는 정의된 경로), Debounce/Throttle 게이트가 값을 내보내는 도중(창 끝·MaxTime·handle:Flush()·leading 통과) 그 통지를 받은 콜백 안에서 같은 게이트의 handle:Cancel()이나 handle:Flush()가 불렸을 때(정의되지 않은 동작 — 지금은 창이 다시 열려 다음 leading 신호가 Time 하나를 더 기다리는 것이 관측됩니다), 디스패치가 quad가 claim하지 않은 인스턴스 — 파괴됐거나(q.dispose든 quad 밖이든) quad가 만들지 않은 것 — 에 돌 때(정의되지 않은 동작; D.<Class>·drive·process 모두, 키마다), 마운트 대상 인스턴스가 더는 claim돼 있지 않은 Slot에 CRUD를 부를 때(정의되지 않은 동작 — 그 변경은 죽은 인스턴스로 갑니다), Tween{…}/Animate{…}에 Info와 편의 필드(Time·Style·Direction·RepeatCount·Reverses·DelayTime)를 함께 줬을 때(Info가 이기므로 그 필드들은 무시됩니다 — Animate에 Time을 State로 줘도 따라가지 않는 경우; Animate의 Info가 State면 그 State가 값을 가진 동안만 무시된다고 알립니다 — nil을 쥐면 편의 필드가 쓰입니다).

  • q.Protocols.DispatchHint — 디스패치가 값에 맞는 핸들러를 못 찾을 때(Quad0076) 그 값이 직접 원인 문장을 주게 하는 능력 브랜드입니다. 값을 만들 때 quad.Protocols.DispatchHint:Register(value)로 등록하고 value:__dispatchHint(key) -> string?을 두면 그 문장이 메시지 꼬리가 됩니다(nil이면 일반 꼬리; 에러 위치는 그대로; 훅이 던지는 것은 정의되지 않은 동작). quad의 Store·Context·Provider·Blocker·AttrKey와 quad-roblox의 Tween이 자기 문장을 등록합니다 — 그래서 Blocker·AttrKey를 값 자리에 두면 “숫자 키 자리로 옮기라”(옮기면 같은 에러) 대신 state:Apply(blocker)·[q.AttrKey(name)] = value를 안내하고, Tween을 없는(오타·읽기 전용·다른 클래스) 프로퍼티 키에 두면 그 키를 의심하라고 안내합니다. 이름 규약: 두 밑줄로 시작하는 이름(__dispatchHint)은 quad가 부르는 공개 훅(계약)이고, 밑줄 하나는 비공개입니다. 브랜드 술어 등록부 q.BrandPredicates와는 별개입니다. 타입은 Protocols: { [string]: Brand? }라 앞으로 능력이 더해져도 바뀌지 않고, 쓰는 쪽은 한 번 nil 확인으로 좁힙니다(local hint = quad.Protocols.DispatchHint; if hint then hint:Register(value) end).

Changed

  • BREAKING — slot:List 재조정 한 사이클 안에서 updateFn이 불리는 순서를 더는 약속하지 않습니다(항목끼리, 사라진 키의 KeyGone끼리, 항목과 사라진 키 사이 모두). 3.2.0은 “항목 먼저, 사라진 키 나중”을 적어 두었고, 이제는 사라진 키를 먼저 정리합니다 — 그래서 키를 전부 바꾸는 :Set(목록 통째 교체)이 원소 수의 제곱이 아니라 원소 수에 비례해 끝납니다(CLI mock에서 5000개 2.2초 → 0.1초). 사이클이 끝난 뒤의 상태는 같습니다. 같은 사이클 안에서 다른 키의 원소나 그 State를 돌려주는 것은 이제 정의되지 않은 동작입니다(결과가 호출 순서에 달려서). 옮기는 법: updateFn이 다른 키의 updateFn이 먼저 불렸는지에 기대지 않게 하세요 — 자기 항목만 보고 원소를 돌려주면 영향이 없습니다.

  • BREAKING — Slot: 원소를 붙이거나 떼는 엔진 호출 안에서 동기로 불린 신호 콜백이 그 연산이 진행 중인 Slot이나 그 조상 Slot을 CRUD하면 Quad0167로 거부합니다 — Roblox에서 SignalBehavior가 Immediate일 때 원소의 q.OnChange("Parent", fn) 콜백이 Add/Remove/Extract/Replace/Splice·마운트·파괴 도중 같은 Slot(통째로 붙고 있는 Slot과 그 안의 Slot 포함)이나 그 위의 조상 Slot에 Add/Remove/Clear 등을 하는 경우입니다(조상까지인 이유: 자식의 Length가 호출 뒤에 갱신되어 그 사이 조상의 자리 계산이 낡은 값을 씁니다). 전에는 에러 없이 부기가 어긋났습니다(Length가 틀림, 다른 원소가 빠짐, 원소가 부기에만 있고 화면에 없음). 바깥 연산은 끝까지 마쳐지고 막히는 것은 콜백의 CRUD뿐이며, Deferred(새 경험의 기본)에서는 콜백이 연산 뒤에 불려 달라지는 것이 없습니다. 이 모양을 쓰던 코드는 그 CRUD를 새 slot:WhenIdle(fn)에 넘기세요(아래 Added). 언마운트 도중의 같은 콜백은 거부하지 않고 고쳤습니다(전에는 그 Add가 떼어지는 옛 호스트에 남았습니다). 따라오는 변화 하나: 엔진 호출 자체가 던진 뒤(예: 인스턴스 순환을 엔진이 거부) 그 Slot은 이후 CRUD를 Quad0167로 거부합니다 — 전에 문서가 적던 Extract 회복은 더는 되지 않습니다(던진 뒤 상태는 계약이 아닙니다). Quad0167의 문구가 이 경우를 포함하게 넓어졌습니다.

  • tostring(tween)이 Info가 있으면 Tween(1, Info)처럼 Info를 보여 줍니다 — Info가 편의 필드보다 우선하므로, 전에는 함께 준 Time을 보여 줘(Tween(1, 0.3s)) 실제로 쓰이지 않는 시간이 찍혔습니다. Info가 없으면 그대로(Tween(1, 0.3s)·Tween(1))입니다.

  • Slot의 CRUD(Add/Replace/Extract(i, new)/Splice/생성자)가 넣기 전에 미리 하는 검사는 이제 원소 종류(마운트 가능한 값·quad 소유·파괴된 Slot 아님) 하나입니다 — 이 검사에 걸리면 전처럼 아무것도 바뀌지 않습니다. 같은 원소를 두 번 넣거나 이미 다른 곳에 마운트된 원소는 그 원소를 자리에 넣는 순간 거부합니다 — 같은 Slot이면 Quad0170 Slot: this element is already in this Slot (a second mount) - reorder with Move/Swap …(slot:Add(a); slot:Add(a)와 q.Slot({ a, a })가 같은 에러 — q.debug가 꺼져 있을 때; 켜져 있으면 한 호출 안 중복은 그 전에 Quad0171), 다른 곳이면 Quad0172. 여러 원소를 받는 호출(Splice·생성자)이 중간 원소에서 이렇게 던지면 앞 원소는 이미 들어가 있습니다(관측 — 계약 아님). 한 호출 안의 중복을 넣기 전에 잡는 Quad0171은 q.debug일 때만 돕니다. State로 넣은 원소는 넣을 때 값을 읽지 않고 :List/:Single 항목처럼 마운트·값 변경 때 검사합니다 — 마운트 전 Slot에 같은 Instance를 쥔 State 둘을 넣으면 넣는 줄 대신 마운트 줄에서 Quad0156이 나며(문구가 그 원인을 말합니다), slot:Add(computed)가 그 계산 함수를 마운트 전에 돌리지 않습니다. 기존 원소 전체를 훑던 검사가 빠져 원소 하나씩 Add하는 비용이 길이에 비례하지 않습니다. Splice가 같은 호출에서 빼는 원소를 다시 넣는 것은 이제 됩니다(빼는 쪽이 먼저 풀립니다 — 전엔 “같은 원소가 두 번”으로 거부).

  • 문서: :Compute 계산 함수 안의 :Set/:Emit과 yield는 정의되지 않은 동작으로 명시했습니다 — 반응형 콜백은 파동 도중 yield하지 않고, 꼬인 파동을 풀어 줄 도구가 없는 곳에서 자기 입력을 되먹이지 않습니다(Effect가 자기 의존성을 :Set해 끝난 뒤 한 번 더 도는 것은 정의된 예외). 레퍼런스가 서술하던 yield 시 동작(Quad0184가 나는 자리)은 계약이 아니라 지금의 관측입니다. Observer는 최종 소비자로 명시했습니다 — 콜백이 다른 대상을 :Set하는 것은 정상이고, 자기 상류(관측 대상이나 그 원천)를 직접 또는 다른 핸들을 거쳐 되먹이는 것은 정의되지 않은 동작입니다(되써서 바로잡아야 하면 Effect로). 엔진을 한 바퀴 도는 q.Out/OnChange 왕복과, Slot의 Offset/Length를 보는 콜백이 Slot을 CRUD해 그 값이 다시 바뀌는 고리(Slot이 스스로 다시 계산해 맞춤)는 여기 해당하지 않습니다. 반응형 콜백 안의 yield는 어느 콜백에서든 정의되지 않은 동작입니다. 같은 맥락의 문서 축소: ref:Wait(thread)의 정의는 “그 Ref가 다음에 :Set될 때 등록된 코루틴을 한 번 resume한다”뿐이고, 한 코루틴을 여러 Ref에 거는 조율(“둘 중 먼저” 등)은 quad가 정의하지 않습니다(코루틴 조율 도구의 몫).

  • state:Get()(:Compute 결과 등)은 이제 재계산을 한 번만 합니다 — 전에는 계산 함수 도중 상류가 움직였으면 같은 :Get() 안에서 다시 계산했습니다. 정의된 경로의 동작은 같습니다(닫힌 게이트 뒤에서 움직인 상류도 여전히 알아채 최신 값을 돌려줍니다). 다른 것은 계산 함수 안의 :Set·yield(정의되지 않은 동작)뿐입니다 — 그때 그 :Get()은 한 파동 뒤처진 값을 돌려줄 수 있고(지금은 다음 :Get()이 따라잡습니다), yield로 겹친 계산이 어느 결과를 남기는지도 정하지 않습니다(멈췄다 돌아온 계산을 따로 다루던 장치를 걷어냈습니다 — 겹침은 q.debug의 경고가 알려 줍니다).

  • strict 타입: nil을 기능으로 받는 입력 자리가 이제 nil이나 q.None을 내놓을 수 있는 State도 받습니다 — 객체 참조 프로퍼티(Adornee·NextSelectionUp·PrimaryPart 등 값이 Instance인 것), 이벤트, 숏핸드(UICorner·UIPadding·UIScale 등)와 그 Modifier setter, 타입드 Attr 슈거(StringAttr 등), Slot 원소 입력, 숫자 키 자리의 State<OnChange 디스크립터?>·State<Ref?>. 런타임은 원래 받던 것이라 동작은 같고 타입만 넓어집니다. Text처럼 엔진이 nil 쓰기를 거부하는 프로퍼티는 그대로 거부합니다. 속성을 지우려고 :Set(q.None)하는 State는 q.Source<<boolean | typeof(q.None)>>(true)처럼 None을 타입에 넣어 선언하세요.

  • BREAKING — 조용히 받아들이던 잘못된 입력 몇 가지를 이제 그 자리에서 거부합니다: :List/:Single의 opts.OwnsElements에 불리언이 아닌 값("false", 0 — 전에는 소유로 처리, Quad0259), :List 데이터 항목 자체가 q.KeyGone인 것(전에는 조용히 빠짐, Quad0260), q.Attr(store)에서 Store 필드에 직접 넣은 함수·테이블 값(전에는 적용할 때 번호 없는 에러). 옮기는 법: OwnsElements에는 true/false만 넘기고, 데이터 배열에서 q.KeyGone을 빼고, q.Attr(store)로 넘길 Store의 필드에는 원시 값·State/Source·q.None만 두세요(함수·테이블은 다른 Store로 빼세요).

  • Effect의 지연 재실행(fn이 자기 의존성을 :Set해 끝난 뒤 한 번 더 도는 것)이 한 번의 반복에서 1만 번 넘게 돌면 Quad0263으로 멈춥니다 — q.debug와 상관없이 늘 켜진 한도입니다. 3.2.0에서는 상한 없이 돌아, 자기 의존성을 계속 바꾸는 fn(Source는 같은 값도 전파하므로 조건 없이 같은 값을 되쓰는 fn 포함)이 에러 없이 끝나지 않았습니다. 1만 회 전까지의 재실행은 그대로이고, 멈춘 Effect는 fn이 던진 것과 같이 죽습니다. 옮기는 법: 되쓰기를 dep:SetIfChanged(c)로 하세요(값이 같으면 아무것도 하지 않습니다 — 위 Added).

  • Slot의 Offset을 보는 콜백이 앞자리의 길이를 바꿔 자리 계산이 그 자리부터 다시 도는 고리(정의된 경로)가 한 번의 계산에서 1만 번 넘게 되감으면 Quad0267로 멈추고, 그 계산을 시작한 줄(CRUD·:Set)에서 던집니다 — Effect의 Quad0263과 같은 수이고 q.debug와 상관없이 늘 켜진 한도입니다. 3.2.0에서는 값마다 넣고 빼기를 뒤집는 콜백이 에러 없이 끝나지 않았습니다. 1만 번 전까지의 되감기는 그대로이고, 되감기가 없는 계산은 목록이 아무리 길어도 걸리지 않습니다. 옮기는 법: 넣고 뺄지를 콜백이 바꾸는 그 길이에 기대지 않는 값으로 정하세요.

  • 비용이 드는 검사 넷을 q.debug 층에 두어 평소 경로를 가볍게 했습니다 — (1)(3)은 3.2.0에서 매번 돌던 검사를 내린 것이고 (2)(4)는 새로 넣은 검사입니다. q.debug가 켜져 있을 때만 돌고, 켜면 그 자리에서 에러로 멈춥니다: (1) 중괄호를 잊은 D.Frame(value)의 Quad0083 — 평소엔 메타테이블이 있는 값과 quad-base의 메타테이블 없는 값(q.AttrKey 등)만 봅니다. 메타테이블 없는 평범한 테이블에 브랜드를 붙이는 프로바이더 값은 꺼져 있으면 그 필드들이 props로 해석됩니다(대개 Quad0076). (2) slot:List 데이터의 Quad0143 중 배열에 키가 덧붙은 데이터({ a, b, extra = 1 }) — 꺼져 있으면 3.2.0처럼 덧붙은 키가 무시됩니다(배열 부분이 빈 테이블은 debug와 상관없이 늘 거부 — 아래 Fixed). (3) Slot을 자기 자신이나 조상 Slot에 넣는 Quad0173 — 꺼져 있으면 넣는 줄은 통과하고 그 트리의 다음 CRUD나 해체가 스택 오버플로나 내부 불변식 에러로 끝납니다(연산마다 다름 — 관측, 계약 아님). (4) quad-roblox에서 Instance를 자기 자신이나 자손의 숫자 키 자리에 놓는 Quad0220 — 꺼져 있으면 3.2.0처럼 Roblox 엔진의 circular reference 에러가 멈추되, quad가 자리를 잡은 뒤에 나는 에러라 그 요소는 버리세요. 개발 중에는 q.debug = true를 켜 두세요.

  • 매치 실패 에러(Quad0076)와 중괄호 누락 에러(Quad0083)가 값이 가진 브랜드 이름을 하나만이 아니라 걸리는 것 전부, 이름순으로 싣습니다(예: brand: Epoch, PreRef, Ref).

  • q.Operator.Band()·Bor()·Bxor()를 인자 없이 쓰면 이제 값을 bit32 규칙으로 정규화합니다(-1 → 4294967295) — 인자 하나일 때와 Bnot과 같아졌습니다. 전에는 값을 그대로 돌려줬습니다.

  • 프로바이더 계약: 자기 값 타입의 브랜드 술어(is<Brand> 모듈 필드)를 설치 때 받은 모듈의 새 집합 quad.BrandPredicates에 :Register(isX)로 등록하세요 — 디스패치 진단(매치 실패 메시지의 브랜드 이름, 중괄호를 빠뜨린 D.Frame(value) 거부)이 등록된 술어만 봅니다. 등록하지 않아도 동작은 그대로이고 그 브랜드의 진단만 약해집니다. quad-roblox의 isTween은 등록돼 있습니다.

  • q.Tag(...)·tag:Added·tag:Removed·q.Tag.Merged의 **인자 자리 nil은 이제 “없음”**입니다(전엔 검증 에러). base:Added(if even then "Even" else nil)처럼 조건을 인자 안에 쓰면 결과가 늘 새 Tag라 대입을 빠뜨릴 수 없습니다. 리스트 안 nil 구멍({ "a", nil, "b" })은 여전히 에러이고, tag:Contains(nil)도 그대로 에러입니다.

  • Info 없이 Time = 0이고 DelayTime/RepeatCount/Reverses가 없거나 0·거짓인 q.Tween{…}은 이제 엔진 트윈 없이 값을 그 자리에서 씁니다(첫 세팅과 같은 스냅). 전에는 0초짜리 엔진 트윈을 만들어 다음 프레임에 썼습니다. 그 셋 중 하나라도 있으면 전과 같습니다.

  • Claim이 트리를 해석하는 단계에서 이미 quad 소유인 인스턴스(루트든 자식이든 — 템플릿에 꽂아 둔 Declaration 위젯, 먼저 Claim한 자식)를 Claim: <이름> is already claimed by quad …로 거부합니다. 아무것도 claim하기 전이라 루트와 형제는 무손상이고, 그 자식을 디스크립터에서 빼면 같은 루트에 다시 걸 수 있습니다. 전에는 자식의 경우 루트와 앞선 형제가 claim된 채 남아 그 루트를 영영 Claim할 수 없었습니다. 자기 백엔드를 만든 쪽: isClaimed가 “nativeClaim이 지나간 것만 참”이어야 이 게이트가 성립합니다(quad-roblox는 원래 그렇습니다).

  • OwnsElements = false인 :List/:Single Slot을 q.dispose하거나 소유하는 부모 Slot이 Remove/Clear로 버리면, 그 Slot이 쥐고 있던 원소 전부의 소유권이 풀립니다(원소는 파괴되지 않고 다른 Slot에 넣거나 q.dispose할 수 있습니다). 그 Slot은 빈 채로 살아남아 다시 쓸 수 있고, 다음 마운트에서 data로부터 처음부터 조정합니다. 전에는 원소가 버려진 Slot에 계속 묶여 있어 already mounted elsewhere/still held by a Slot으로 거부됐고, Slot 참조를 버린 뒤엔 되돌릴 길이 없었습니다. Extract로 빼는 경우는 전처럼 원소를 쥔 채 옮겨갑니다.

  • BREAKING — Slot이 quad 밖에서 만든(claim되지 않은) 인스턴스를 더는 원소로 받지 않습니다. 전에는 조용히 들어갔고 죽음 추적이 없어 밖에서 Destroy되면 Slot이 stale해졌습니다. 이제 Add·생성자·Replace·Extract·Splice·List/Single이 Slot: this element is not claimed by quad …로 거부합니다. State에 담긴 원소는 넣을 때 값을 읽지 않고 마운트·값 변경 때 거부합니다(마운트된 Slot이면 넣는 줄이 곧 그때이고, 마운트 전 Slot이면 마운트 줄에서 납니다). List/Single의 updateFn이 그런 원소를 돌려주면 재조정 도중의 예외라 그 Slot의 Length가 더 이상 발행되지 않습니다(재조정은 계속 돌지만 같은 부모의 형제 Slot이 옛 오프셋에 앉습니다, 에러 없이) — 원소를 만들 때 claim하세요. 옮기는 법: 그 인스턴스를 먼저 Claim으로 넘겨받거나 Declaration으로 만드세요 — Slot이 대신 claim하지 않습니다. 정적 자식 자리도 같습니다 — D.Frame { Instance.new("TextLabel") }처럼 숫자 키에 놓은 미claim 인스턴스는 InstanceChild: this Instance is not claimed by quad …로 거부됩니다. 그 자리의 State<Instance?>를 나중에 미claim 인스턴스로 :Set하는 경우도 같은 거부인데, 이때는 옛 자식이 이미 내려간 뒤(체인 철거 뒤 새 값 처리)라 그 자리는 빈 채로 남습니다.

  • BREAKING — 정적 자식과 숏핸드 관리 자식도 “한 값은 한 자리” 규칙 안에 들어갑니다. 전에는 같은 Instance를 D.Frame { c } 둘에 놓으면 에러 없이 둘째가 가져가고 첫째의 자리 부기는 남아, 첫째가 자리를 내릴 때 살아 있는 둘째 트리에서 그 자식을 뜯어냈습니다. { c, c }도 통과했고, 정적 자식을 slot:Add하거나 q.dispose하는 것도 막히지 않았습니다. 이제 둘째 자리는 Bookkeeping.claimOwnerAt: this element is already mounted elsewhere …로 거부되고(첫째 트리는 그대로 — 다만 그 둘째 자리가 State에 담긴 자리라면 거기 앉아 있던 옛 자식은 거부 전에 이미 내려가 그 자리는 빈 채로 남습니다, 미claim 인스턴스를 넣을 때와 같습니다), 앉아 있는 자식은 q.dispose가 dispose: this value is still held by a Slot or a mounted position …로 거부합니다. UICorner = 8 같은 숏핸드가 만든 관리 자식(_quad_round·_quad_padding·_quad_scale)도 같습니다 — 전에는 q.dispose가 조용히 파괴한 뒤 다음 값 발행이 파괴된 인스턴스에 써졌습니다. 옮기는 법: 한 Instance를 다른 자리로 옮기려면 먼저 그 자리에서 내리세요(자리를 쥔 State를 :Set(nil), Slot이면 Extract). 두 곳에 같은 모양이 필요하면 Instance를 둘 만드세요. 제대로 돌던 코드는 영향이 없습니다 — 이 변경은 원래 막혔어야 할 것을 막은 것입니다.

  • BREAKING(좁은 범위) — :Compute 계산 함수의 재진입이 에러가 됩니다. 계산 함수가 자기 노드의 값을 읽는 의존성 순환(직접, 또는 하류 State를 거쳐)은 전에는 내부 파일을 가리키는 stack overflow였고, 그것을 q.Fallback 같은 프레임이 삼키면 반쯤 계산된 값이 유효 캐시로 굳었습니다. yield하는 계산 함수를 다른 코루틴이 그 사이 읽으면 함수가 두 번 돌았습니다. 이제 둘 다 읽는 줄에서 State: Compute fn is already running for this value …로 즉시 에러입니다. 부수 규칙 하나: 계산 함수가 던지면 같은 세대의 재읽기도 그 문구로 에러이고(전에는 함수가 다시 돌았습니다), 상류가 바뀌면 다시 돕니다. 잡히지 않는 모양 하나: 계산 함수가 자기 의존성을 :Set한 뒤 자기 값을 읽는 순환은 세대가 바뀌어 새 계산으로 보이므로 전처럼 스택 오버플로입니다(계산 함수가 자기 의존성을 매번 다시 쓰는 것은 원래 정의되지 않은 동작). 순환·yield·던지는 계산 함수를 쓰지 않던 코드는 영향이 없습니다. 옮기는 법: 계산 함수가 던진 뒤 같은 값에서 다시 읽어 재시도하던 코드는 상류를 고쳐 :Set한 뒤 읽으세요. 핸들러 작성자용 한 줄: 길이로 등록한 State의 계산 함수가 같은 owner의 부기를 건드리면 이 에러입니다(중첩 재계산이 자기 값을 읽습니다).

  • quad-roblox의 Event·OnChange 핸들러 우선순위가 HANDLER_PRIORITY_NORMAL - 1로 내려갔습니다(각각 Property·InstanceChild와 같은 값이었음). 판정이 서로 배타적이라 동작은 같고, q.debug를 켰을 때 설치 시점에 찍히던 “priority tie” 안내 두 줄이 사라집니다. 자기 핸들러를 NORMAL 근처에 두는 플러그인은 q.Dispatch.listHandlers()로 자리를 확인하세요.

  • 자리에 놓인 State를 벗겨 실제 값을 같은 자리의 한 칸 아래로 다시 넘기는 내장 핸들러의 이름이 StoreBind에서 StateHandler로 바뀌었습니다 — q.Dispatch.listHandlers()가 돌려주는 name과, 에러 위치·traceback에 보이는 quad 내부 파일 이름(StateHandler.luau)이 달라집니다. 이름은 진단용이라 동작은 같습니다. 핸들러 이름을 비교하는 코드가 있다면 새 이름으로 바꾸세요.

  • BREAKING(타입만) — 옵션 테이블의 필드·원소가 읽기 전용 read로 선언돼, 옵션 테이블을 변수에 담아 넘겨도 strict 타입 검사가 통과합니다 — q.Debounce/q.Throttle의 옵션, slot:List/slot:Single의 opts, 그리고 q.Slot(initial)의 배열. 전에는 local opts = { Time = 0.3 } 뒤 q.Debounce(opts)가 가변 필드 불변성으로 거부됐습니다. 주석 없이 넘기던 코드는 그대로 통과하고, 깨지는 것은 옵션 타입을 주석으로 단 테이블을 만든 뒤 o.Leading = true처럼 고치는 코드뿐입니다(strict 에러). 옮기는 법: 만들 때 값을 다 넣거나 주석 없는 테이블을 쓰세요.

  • BREAKING(타입만) — 생성 Declaration의 레거시 프로퍼티가 허용목록으로 줄었습니다. 남는 것은 Font·Transparency(Hidden)·FontSize·TextWrap(Deprecated) 넷이고, Draggable·BillboardGui.DistanceLowerLimit/DistanceUpperLimit·Camera.focus(쓰기 표면)와 className(q.OnChange 읽기 표면 — 그 변경 시그널은 실기기에서 발화하지 않습니다)은 타입에서 빠집니다. 런타임은 같습니다(디스패치는 엔진 리플렉션을 따르므로 Draggable = true는 여전히 써집니다 — 다만 strict에서 타입 에러). 옮기는 법: 빠진 키를 쓰는 곳은 현행 API로 바꾸거나, 꼭 필요하면 D.New/q.Dispatch.drive에 any로 넘기세요.

  • BREAKING(타입만) — q.OnChange("Parent", fn)의 콜백 인자와 AncestryChanged 이벤트의 parent 인자가 Instance에서 Instance?로 바뀌었습니다. 엔진은 인스턴스가 트리에서 빠질 때 이 두 자리에 nil을 주는데(그 순간을 잡는 것이 이 API를 쓰는 주된 이유), 생성 타입이 Instance라 function(p: Instance) print(p.Name) end가 strict를 통과한 채 실기기에서 죽었습니다. 런타임은 같습니다. 옮기는 법: 콜백 주석을 Instance?로 바꾸고 본문에서 nil을 먼저 거르세요 — 주석을 빼면 strict가 사용 모양에서 Instance로 추론해 nil 팔을 못 받아 오히려 에러가 납니다(3.2.0에선 통과하던 모양). Camera.CameraSubject·Model.PrimaryPart는 쓰기 자리라 그대로입니다.

  • BREAKING(타입만) — q.Effect가 돌려주는 핸들의 타입 이름이 EffectHandle에서 Effect로, 입력 자리 마커가 EffectHandleMarker에서 EffectMarker로 바뀌었습니다. state:Observer → Observer, q.Slot → Slot처럼 생성자와 타입 이름이 같은 규칙에서 이것만 벗어나 있었습니다(술어 isEffect와도 맞춤). 런타임은 같습니다. 옮기는 법: 주석·재수출의 EffectHandle을 Effect로(설정 모듈의 export type EffectHandle = QuadTypes.EffectHandle → export type Effect = QuadTypes.Effect).

  • BREAKING(프로바이더 작성자) — 백엔드가 심는 생명주기 op가 바뀌었습니다. 전에는 백엔드가 bindLifetime/unbindLifetime/canBound/canExecute 넷을 통째로 구현했고, 그러려면 Observer/Effect의 밑줄 훅 셋을 정해진 순서로 불러야 했습니다. 이제 그 넷은 quad-base가 직접 구현하고, 백엔드는 인스턴스 쪽 부기만 담은 hold op 넷을 심습니다 — holdLifetime(inst, value)(값을 인스턴스의 강참조 홀더에 넣고 생존 근거를 남김, quad 소유가 아니면 던짐), releaseLifetime(value)(그 역), isHeld(value)(근거가 아직 살아 있는가), isHeldBy(value, inst)(이 인스턴스가 쥐고 있는가). 옮기는 법: 자기 백엔드의 bindLifetime 본문에서 gchold/gcconn 부기 부분만 holdLifetime으로, 해제 부분만 releaseLifetime으로 옮기고, 살아 있음 판정의 gcconn 절반을 isHeld로 두세요. 나머지 줄(nil 검사, 거부 메시지, 훅 호출, .Subscribed 판정)은 지우면 됩니다 — quad-base가 합니다. 앱 코드에는 영향이 없습니다(quad-roblox 사용자는 그대로).

  • BREAKING — 백엔드가 심는 계약 op가 q.Backend로, Length/Offset 부기 함수가 q.Bookkeeping으로 옮겨졌습니다. 앱 코드가 부를 표면이 아닌 것들이 q. 자동완성에 섞여 있었습니다. 멤버 이름은 그대로입니다.

    • q.Backend: 백엔드가 심는 슬롯 스물 — holdLifetime/releaseLifetime/isHeld/isHeldBy(아래 hold op 항목), nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap/nativeDispose, nativeClaim/isClaimed/nativeFindChild/isInst/onDestroying, addTag/removeTag/setAttr, setTimeout/clearTimeout.
    • q.Bookkeeping: setLength/setOffsetSource/setEmpty/getOffsetAt/getBookkeeping — q.Dispatch에서는 빠졌습니다. getBlocker(배치 Blocker 조회)는 옮기지 않고 공개 표면에서 빠졌습니다 — 배치를 여닫는 것은 drive와 Slot의 몫이고(닫으며 재계산하는 경로가 내부에만 있어 핸들러가 직접 열면 제대로 닫을 수 없었습니다), 진단 읽기는 getBookkeeping이 합니다. 에러 문구의 접두도 Dispatch.에서 Bookkeeping.으로 바뀌었습니다.
    • q.dispose, q.newMapperClass, is* 술어는 그대로 q.에 있습니다.
    • 옮기는 법: 백엔드 작성자는 quad.nativeInsert = …를 quad.Backend.nativeInsert = …로 옮기되 옛 quad.bindLifetime = …은 옮기지 말고 지우세요(그 넷은 이제 quad-base가 q.Backend 위에 조립합니다 — quad.Backend.bindLifetime = …로 옮기면 base의 게이트를 덮어쓴 채 에러 없이 돌아갑니다); 대신 hold op 넷을 심으세요. 핸들러 작성자는 q.Dispatch.setLength(…)를 q.Bookkeeping.setLength(…)로, 테스트에서 q.bindLifetime(…)을 부르던 코드는 q.Backend.bindLifetime(…)으로. 옛 모양(quad.nativeInsert = …)은 에러 없이 받아들여지고 첫 사용에서 … is not available - no backend has installed …가 나니, 프로바이더를 이미 설치했는데 이 문구가 보이면 이 이관을 의심하세요.
  • Observer·Effect·Slot·State를 만든 quad 인스턴스가 아닌 다른 인스턴스의 트리에 놓으면 그 자리에서 에러가 납니다(…: this value was made by another quad module instance …) — 숫자 키 자리, 프로퍼티 값, slot:List의 데이터 State. 전에는 에러 없이 받아들여졌지만 생명주기 기록이 인스턴스마다 따로라 그 값이 조용히 돌지 않았습니다(quad_base 사본이 둘 생긴 프로젝트에서 흔히 밟는 경우). 제대로 돌던 코드는 영향이 없습니다. 의존성으로만 잇는 것(q.Effect(fn, 다른 인스턴스의 Source))은 그대로 되고, Ref·Blocker 같은 공유 값은 검사하지 않습니다(두 인스턴스에 걸쳐 쓰면 정의되지 않은 동작).

  • BREAKING — quad_types의 Brand()가 돌려주는 객체의 메소드가 :register/:is에서 :Register/:Is로 바뀌었습니다. 자기 값 타입에 브랜드를 붙이는 백엔드·플러그인 작성자에게만 해당하고, q.isState 같은 술어를 쓰는 코드는 그대로입니다. 옮기는 법: 호출부의 메소드 이름 첫 글자만 대문자로.

  • 문서에 명시: _로 시작하는 필드(_bookkeeping 등)는 공개 표면이 아니며 예고 없이 바뀝니다.

  • 문서에 명시: 에러 메시지는 사람이 읽는 진단이지 API가 아닙니다 — 문구는 예고 없이 다듬어질 수 있으니 문구로 분기하지 마세요.

  • 문서에 명시(정의되지 않은 동작): 같은 원천을 구독한 하류·Ref 콜백끼리의 발화 순서와 KeyGone 호출끼리의 순서(등록 순서가 아닙니다), Observer·Effect·계산 함수·Ref 콜백 안의 yield, updateFn이 도중에 던진 뒤의 그 Slot(지금은 Length 발행이 멈춰 형제 Slot이 옛 오프셋에 앉습니다 — 관측이지 계약은 아닙니다), 서로를 설치하는 플러그인 순환.

  • BREAKING — slot:List/slot:Single의 옵션 Owned가 OwnsElements로 바뀌었습니다. 뜻은 그대로입니다(기본 true, false면 이 Slot이 원소의 수명을 책임지지 않음). 옛 키는 조용히 무시되어 기본값(소유)으로 돌아가니 반드시 옮기세요 — q.debug를 켜면 모르는 키로 알려 줍니다(strict 타입도 이 키를 잡지 못합니다). 옮기는 법: { Owned = false }를 { OwnsElements = false }로.

  • q.Effect의 fn이 함수도 nil도 아닌 값을 돌려주면 그 자리에서 Effect: fn must return a cleanup function or nothing (got …)을 던집니다(사용자 줄 blame). 전에는 값을 저장했다가 다음 정리 때 quad 내부에서 attempt to call …로 터지고 그 Effect가 조용히 멈췄습니다. 연결 객체를 돌려주던 코드는 return function() conn:Disconnect() end로 감싸세요.

  • q:AddPlugin이 같은 플러그인 함수를 다시 받으면 아무것도 하지 않습니다(q:RunInit과 같은 identity 기준). 여러 스크립트가 각자 설치해도 한 번만 돕니다. 도중에 던진 플러그인은 지금은 설치된 것으로 치지 않아 다시 부르면 처음부터 돕니다(던진 뒤의 동작이라 관측이지 계약은 아닙니다).

  • quad_roblox가 요구하는 quad_base 버전이 정확히 일치에서 같은 메이저 안의 하한(릴리즈 시점 버전이 하한 — 예: 3.3.0 릴리즈면 3.3^.0^ = 3.3.0 이상, 메이저 3; bump가 갱신)으로 풀렸습니다. quad_base만 올라가도 UseProvider의 버전 검사(Quad0228)에 막히지 않습니다. 프리릴리즈 빌드는 그 빌드만 받습니다(패턴 3.3.0-rc.2).

  • BREAKING — q.Store의 필드 이름으로 __(밑줄 둘)로 시작하는 이름을 전부 쓸 수 없습니다(전에는 __reservedCheck 하나만 막았습니다). 생성자와 :Of 모두 Store: "{name}" is a reserved store key를 던지고 strict 타입 검사도 알립니다. 대신 Store의 메소드는 Of/Names에서 더 늘리지 않기로 해, 그 둘과 __ 접두만 피하면 앞으로 필드 이름이 새 메소드와 부딪히지 않습니다. 옮기는 법: __로 시작하는 필드 이름을 다른 이름으로 바꾸세요.

  • quad_error와 type_version_check의 변경은 이제 각 폴더의 CHANGELOG.md에 적습니다. 둘은 quad에 묶이지 않은 범용 패키지라 4.0.0부터 각자의 SemVer로 올라가고, 그 전(3.x 안정화 구간)에는 quad와 같은 번호로 같이 게시됩니다. 이번 릴리즈에는 type_version_check에 BREAKING이 하나 있습니다 — 버전 패턴 문법을 넓히고(| 대안, N^ 하한) 규칙 하나를 좁혔습니다(옮기는 법은 그 폴더의 CHANGELOG.md). 둘은 의존성으로 따라 들어오니 소비자가 적을 일은 여전히 없습니다.

  • BREAKING(타입만) — slot.Length/slot.Offset과 updateFn의 offset 인자가 Source<number>에서 읽기 전용 State<number>로 좁혀졌습니다. 값을 채우는 것은 quad뿐이라 :Set을 부르면 조용히 레이아웃이 어긋났는데, 이제 strict에서 막힙니다. :Get/:Compute/:Observer/:Depend는 그대로입니다. 옮기는 법: slot.Length/slot.Offset이나 updateFn의 ctx.Offset에 Source<number>라고 주석을 달았다면 State<number>로 바꾸세요(updateFn의 인자 모양 변화는 아래 항목).

  • BREAKING — state:With(...)가 state:Depend(...)로 이름이 바뀌었습니다. 동작은 그대로(값은 리시버 것, 넘긴 deps가 변할 때도 다시 발행)이고, :Compute(fn, ...deps)·q.Effect(fn, ...deps)의 deps와 같은 낱말이 됐습니다. With는 “값이 따라온다”로 읽히기 쉬웠습니다. 옮기는 법: :With(를 :Depend(로. 옛 이름은 남기지 않았습니다.

  • BREAKING — slot:List/slot:Single의 updateFn이 위치 인자 다섯 대신 테이블 하나를 받습니다: updateFn(ctx) — ctx.Item/ctx.Index/ctx.Offset/ctx.Prev/ctx.UserData. :Single에도 Index가 들어와 같은 updateFn을 두 곳에 쓸 수 있습니다. 반환 (result, userdata)는 그대로입니다. 호출마다 새 테이블이라 안에서 만든 클로저가 ctx를 붙잡아도 안전합니다. 옮기는 법: function(item, index, offset, prev, ud)를 function(ctx)로 바꾸고 필드로 읽으세요(:Single은 옛 (item, offset, prev, ud)). 옛 모양은 런타임 에러 없이 틀립니다 — 첫 인자로 ctx 테이블이 들어와 item.label·index가 nil인 행을 만들고 if prev then return prev end 재사용이 조용히 사라지며, 키가 빠질 때 가서야 Slot:List: KeyGone accepts only …로 죽습니다. strict는 잡습니다.

  • BREAKING — blocker.IsBlocked와 blocker:IsOn()이 blocker.Blocking 필드 하나로 바뀌었습니다. 같은 값을 읽는 길이 둘이었습니다. 진행형인 이유는 “지금 막고 있다”를 뜻하기 때문입니다(과거형은 “막아 낸 적이 있다”로 읽힙니다). 옮기는 법: blocker:IsOn()과 blocker.IsBlocked를 blocker.Blocking으로. :IsOn()은 missing method로 죽지만 **.IsBlocked는 에러 없이 nil**이라 if blocker.IsBlocked then이 늘 거짓이 됩니다 — strict는 잡습니다.

  • BREAKING — tag:Names()가 없어지고 Tag를 직접 반복합니다: for name in tag do(Luau 일반화 반복 __iter, 새 테이블을 만들지 않음). store:Names()는 배열을 돌려주는데 tag:Names()는 이터레이터라 이름이 같고 모양이 달랐습니다. 옛 호출은 없는 메소드라 즉시 에러가 납니다. 옮기는 법: for name in tag:Names() do를 for name in tag do로. pairs(tag)는 이름을 돌지 않습니다(Luau가 __pairs를 __iter로 대체).

  • BREAKING(타입만) — Provider<T>(Context의 열쇠)가 T에 불변이 됐습니다. 전에는 Provider<number>에 문자열을 ctx:Set 해도 타입 검사가 통과해 ctx:Get의 약속이 깨졌습니다. 이제 넣는 값의 타입이 열쇠와 같아야 하고, Provider<Frame>을 Provider<Instance> 자리에 넘길 수도 없습니다(Ref<T>와 같은 규칙). 런타임 동작은 같습니다. 옮기는 법: 열쇠를 넘겨받는 자리의 타입을 그 열쇠와 같게 적으세요.

  • BREAKING(타입만) — 사용자가 읽기만 하는 상태 필드에 read가 붙었습니다: Ref의 Value/Revision/Callbacks/WeakCallbacks, Source.Revision과 quad_types의 Epoch 타입의 Revision, Observer·Effect의 Subscribed, Blocker.Blocking(위 개명), AttrKey의 Name, Timeout._native. 런타임 동작은 같습니다. 옮기는 법: 이 필드에 직접 대입하던 코드는 ref:Set(v), :Subscribe()/:Unsubscribe(), blocker:On()/:Off() 같은 메소드로 바꾸세요.

  • BREAKING — q.Operator의 Sum/Product/Min/Max/Band/Bor/Bxor가 값이 nil인 State 인자를 조용히 건너뛰고 나머지만 계산하던 것(Clamp/Shl은 내부 에러) — 읽는 시점에 Operator.Sum: argument #N is a State whose current value is nil …로 던집니다.

  • BREAKING — q.Context가 인자를 받지 않습니다. q.Context({ [P] = v })처럼 초기값을 넘기면 전에는 조용히 버려 나중 :Get(P)에서 엉뚱한 에러로 죽었습니다 — 이제 호출 줄에서 Quad0255로 거부합니다. 옮기는 법: q.Context()로 빈 가방을 만들고 ctx:Set(provider, value)로 채우세요.

  • BREAKING — q.Animate의 CanAnimate는 불리언(또는 불리언 State)만 받습니다. 불리언이 아닌 값("no"·0·함수)은 전에는 참으로 취급돼 조용히 늘 애니메이션했습니다 — 이제 Quad0253으로 거부합니다(State가 쥔 값은 실행 때 Quad0254, nil은 생략과 같음). 옮기는 법: CanAnimate = flag ~= nil처럼 불리언으로 바꿔 넘기세요.

  • BREAKING — q.Animate의 콜백(Started/Completed/Cancelled)에 State를 줄 수 없습니다. 전에는 애니메이션할 때는 동작하고 CanAnimate = false일 때만 Quad0246으로 죽었습니다 — 문서와 타입대로, 이제 Animate{ … } 호출 줄에서 늘 Quad0246으로 거부합니다. 옮기는 법: 콜백은 함수로 넘기고, 바뀌는 동작이 필요하면 그 함수 안에서 State를 읽으세요.

  • BREAKING — 중첩 :List/:Single Slot이 마운트되는 동안 그 조상 Slot을 CRUD하면 Quad0167로 거부합니다. 첫 updateFn(또는 그때 동기로 발화하는 Observer)이 조상 Slot을 고치면 전에는 내부 에러(bindLifetime: value is already bound)로 죽고 그 트리의 부기가 영구히 깨지거나, 이미 마운트된 트리에 넣을 때는 조부모 이상의 Remove/Clear가 통과해 유령 자리를 남겼습니다. 막는 범위는 바로 위 부모부터 맨 위까지의 조상 Slot 전부이고, 그 창 안에서 무엇이든 던지면 그 사슬의 Slot 전부가 이후 CRUD를 Quad0167로 거부합니다(사용자 코드의 예외를 복구하지 않는 계약). 자기 자식 Slot을 채우거나 마운트가 끝난 뒤 조상을 바꾸던 코드는 영향이 없습니다. 옮기는 법: 그 자리에서 조상을 고쳐야 하면 slot:WhenIdle(fn)(위 Added)으로 넘기세요.

  • BREAKING — mod:Field(fn)의 변환 함수가 함수를 돌려주면 setter 줄에서 거부합니다(Quad0264). 전에는 생 함수가 필드에 들어가 이벤트 이름 필드라면 핸들러로 연결됐습니다 — 초기 필드 테이블로 같은 값을 넣으면 이미 Quad0063으로 거부되던 것과 맞췄습니다. 옮기는 법: 이벤트 핸들러는 Modifier가 아니라 Declaration의 인라인 키로 넘기세요(꼭 Modifier에 담아야 하면 State로 감싸세요).

  • BREAKING(프로바이더·핸들러 작성자) — q.Backend.bindLifetime/unbindLifetime이 원시값(숫자·문자열·불리언·buffer·thread)을 거부합니다(Quad0265). 전에는 통과해 무관한 두 바인딩이 같은 값으로 부딪치거나 백엔드 부기 자리를 덮을 수 있었고, NaN이면 VM 에러였습니다. 옮기는 법: 수명을 묶을 값은 테이블·함수·userdata로 넘기세요(원시값을 쥔 테이블이나 클로저를 묶으세요).

  • BREAKING — state:Observer(fn)가 첫 발화 전에 구독자 집합에 합류합니다. 그래서 첫 발화 도중 대상이 바뀌면(콜백이 자기 대상을 :Set하든, 콜백이 깨운 다른 코드가 바꾸든) 그 변경은 버려지지 않고 보류됐다가, 핸들이 묶이거나 구독될 때 콜백이 한 번 더 돌며 반영됩니다(첫 발화 안에서 다시 돌지는 않습니다 — 그래서 게시 전 잠깐 있던 첫 발화 반복 한도 Quad0262도 폐기됐습니다; 아래 Fixed의 유실이 이것으로 고쳐졌습니다). 전에는 그 변경이 버려졌습니다. 첫 발화 도중 대상을 바꾸는 것 자체는 콜백이 자기 상류를 되먹이는 것이라 정의되지 않은 동작입니다(위 문서 항목). 첫 발화의 콜백이 던지면 그 핸들은 상류의 구독자 집합(약한 참조)에 남았다가 수거되며, 그동안 콜백은 돌지 않습니다. 옮기는 법: 첫 발화에서 대상을 되쓰는 콜백은 Effect로 옮기거나, 범위 강제는 값을 받는 자리에서 깎아 :Set하세요.

  • Source를 직접 관측하는 Observer와 Source·Ref를 의존성으로 둔 Effect가 같은 변경으로 두 번 돌지 않습니다 — 파동 도중 다른 콜백이 핸들을 구독·바인딩해 보류분 재생이 먼저 돈 경우, 그리고 (Observer) 다른 콜백이 같은 Source를 다시 :Set해 안쪽 파동이 먼저 닿은 경우에 뒤늦은 같은 통지를 건너뜁니다. 파생 State(:Compute 등)를 거쳐 관측하는 핸들은 이 경우 전처럼 같은 값으로 한 번 더 돌 수 있습니다. 통지 횟수가 아니라 값에 기대던 코드는 영향이 없습니다.

  • q.Effect가 State·Source 의존성마다 따로 만들던 내부 구독 핸들이 없어져 Effect를 만드는 비용과 메모리가 줄었습니다.

  • 문서에 명시: Effect의 첫 실행 fn 안에서 부른 Rerun()(또는 의존성 :Set)은 구독·바인딩 전이어도 보류되지 않고 그 실행이 끝난 자리에서 한 번 더 돕니다(동작은 전부터 이랬고, 레퍼런스가 “보류”라고 잘못 적고 있었습니다). 살아 있는 Observer의 콜백이 자기 대상을 되쓰는 것(clamp 등)은 정의되지 않은 동작입니다 — 지금은 콜백 안에서 재귀로 한 번 더 돌고 바깥 콜백의 나머지 부분이 옛 값으로 이어지는 것이 관측되지만 약속이 아닙니다. 되써서 바로잡아야 하면 fn이 끝난 뒤로 미루는 Effect를 쓰세요.

Fixed

  • state:Apply(obj)에 사용자가 쓴 __apply 테이블을 넘기면 --!strict에서 “None of the overloads”로 거부되던 것 — { __apply = function(self, state) … end }가 매개변수 주석 없이도, state: State<number>로 주석해도 통과합니다(전에는 self: any, state: any·반환 any일 때만 통과). 타입만 넓혔고 런타임은 그대로입니다.

  • 엔진 신호가 즉시 배달되는 환경(Roblox SignalBehavior Immediate)에서, 프로퍼티 쓰기가 동기로 일으킨 q.Out·OnChange 왕복이 같은 프로퍼티에 Tween을 다시 넣으면(상류에 Animate가 있는 경우 등) 그 트윈이 기록 없이 재생돼 인스턴스를 철거하거나 다른 값을 써도 취소되지 않았습니다(한 경로에서는 엔진 트윈 둘이 동시에 재생). 이제 먼저 진행 중이던 처리가 쓰기 직후 자리를 다시 확인하고 물러나, 나중에 들어온 값이 그 자리를 차지합니다 — 물러난 값의 Started·Completed는 불리지 않습니다. Deferred에서는 달라지는 것이 없습니다. 이 왕복(엔진이 quad가 쓴 값을 되돌려 보내는 것)은 처리 도중 돌아와도 정의된 경로로 문서에 적었고, 콜백이 스스로 바꾼 값을 같은 자리에 되넣는 것은 지금처럼 정의되지 않습니다.

  • 설치된 프로바이더가 붙인 브랜드의 값(예: Tween)이 맞는 핸들러를 찾지 못할 때, “프로바이더가 초기화됐는지 확인하라”는 엉뚱한 힌트 대신 quad 값용 힌트를 보여 줍니다(문자 키라면 그 요소의 필드가 아닐 수 있다 — 오타, 읽기 전용, 다른 클래스의 필드).

  • state:Observer(fn)의 첫 발화 도중 일어난 대상 변경이 유실되던 것 — 전에는 콜백이 옛 값에 머물고 구독·바인딩한 뒤에도 재생되지 않았습니다. 이제는 보류됐다가 구독·바인딩할 때 한 번 재생됩니다(위 Changed). 같은 원인의 증상 둘도 사라집니다: 형제 Slot의 Offset을 관측하는 콜백이 막 붙는 Slot 서브트리에 원소를 더하면 뒤 형제의 Offset이 에러 없이 옛 값에 남던 것(Slot의 Offset/Length를 보는 콜백의 CRUD가 그 값으로 다시 돌아오는 고리는 Slot이 스스로 다시 계산해 맞추는 정의된 경로입니다), 그리고 :List의 updateFn이 만든 원소의 OnChange가 마운트 중에 동기로 발화해(Immediate 시그널) data를 :Set하면 그 변경이 목록에 반영되지 않던 것(지금은 첫 재조정이 끝난 뒤 새 데이터로 한 번 더 재조정되는 것이 관측됩니다 — 다만 updateFn이 만든 원소가 그 목록의 data를 바꾸는 것은 여전히 정의되지 않은 동작이라 보장하지 않습니다).

  • 게이트(Blocker·state:Gate, 그 위의 Debounce/Throttle)가 막고 있는 동안 만든 Effect가 그 게이트 뒤의 State를 의존성으로 두면, 게이트가 풀릴 때의 통지를 놓치던 것 — 이제 한 번 더 돕니다(막기 전에 만든 Effect와 같습니다; 값은 첫 실행 때와 같을 수 있습니다).

  • ref:Wait(thread)로 등록해 둔 코루틴이 그 사이 끝났거나 coroutine.close로 닫혔으면 이제 :Set이 그 대기자를 건너뜁니다. 전에는 그런 대기자 때문에 :Set이 번호 없는 cannot resume dead coroutine으로 던지고 나머지 콜백을 건너뛰었습니다. (한 코루틴을 여러 Ref에 거는 조율은 quad가 정의하지 않습니다 — 위 Changed의 문서 항목.)

  • q.Debounce/q.Throttle의 Time/MaxTime에 math.huge를 주면 통과해 게이트가 Flush 전까지(Throttle은 첫 통과 뒤 영원히) 값을 붙들던 것 — 이제 음수·NaN과 같이 거부합니다(Quad0041, State가 쥔 값은 그 값을 읽는 때 Quad0039). 두 에러는 받은 값이 숫자면 타입 대신 그 값(got -1·got inf)을 보여 주고, setTimeout의 Quad0216도 같은 꼬리(got -1·got nan)를 씁니다 — 단 setTimeout은 math.huge를 여전히 받습니다.

  • Slot에 넣은 State의 값 사슬이 자기에게 돌아오면(st:Set(st), a가 b를·b가 a를 담음) 스택 오버플로로 죽고 그 Slot이 굳던 것 — 이제 q.debug가 켜져 있으면 프로퍼티 자리와 같은 Quad0070 State: the value chain is circular …로 Slot이 그 값을 읽는 때 — 그 :Set 줄, 또는 마운트(이미 마운트된 Slot이면 넣는 줄) — 거부하고(마운트 전 Slot에 넣는 줄에선 값을 읽지 않으므로 debug에서도 검사하지 않습니다), 그 뒤 Slot의 상태는 정해져 있지 않습니다(지금은 이전 원소를 보인 채 다음 :Set에서 이어지는 것이 관측됨 — 계약 아님). 사슬을 끝까지 따라가는 검사라 debug 층에 둡니다 — 꺼져 있으면 전처럼 스택 오버플로로 끝나는 정의되지 않은 동작입니다.

  • Modifier의 필드에 Claim/Mapper 디스크립터를 넣으면 그 Modifier를 만드는 줄에서 Quad0057/Quad0064로 거부합니다(전에는 적용할 때 Quad0076).

  • q.Bookkeeping.setLength에 값이 잘못된(음수 등) State를 넘기면 에러는 났지만 길이 목록을 이미 쓴 뒤라 오프셋이 조용히 어긋나고 그 자리가 다시는 움직이지 않던 것 — 이제 부기를 쓰기 전에 그 State의 현재 값을 검사합니다(Quad0021).

  • q.Animate{ … }를 State가 아닌 곳(mod:Apply·tag:Apply·필드가 비었거나 평범한 값인 Modifier setter의 변환 함수 자리)에 붙이면 quad 내부 줄에서 VM 에러(attempt to index nil with 'Get' 등)로 죽던 것 — 이제 Quad0252 Animate: Apply target must be a State …로 거부합니다.

  • 생성 Modifier setter(D.Modifier.Frame():LayoutOrder(x) 등)에 nil이나 number? 같은 옵셔널 값을 넘기는 호출이 strict에서 타입 에러였던 것 — 문서대로 nil은 “그 필드 없음”으로 받습니다(생성자 테이블 D.Modifier.Frame({ LayoutOrder = maybe })와 같은 동작). 런타임은 원래 받았습니다.

  • fn이 던져 죽은 Effect를 :Unsubscribe()/:WeakUnsubscribe()로 놓아줄 수 있습니다. 전에는 죽은 핸들의 네 진입점이 전부 cannot change subscription from inside fn or cleanup으로 거부돼 강한 구독이 그 핸들과 상류를 모듈 수명 동안 붙들었습니다. Observer도 같습니다 — 콜백이 던져 굳은 핸들을 해제할 수 있고, 굳은 핸들은 (재)구독·재바인드만 막히지 발화는 계속된다는 것을 문서가 바로 적습니다. 부수로 콜백 안에서 자기를 해제하는 것도 됩니다(해제는 콜백을 다시 부르지 않습니다).

  • Effect의 fn이 도는 동안 핸들이 실행 자격을 잃으면(fn 안에서 자기를 해제했거나, fn이 yield하는 사이 묶인 인스턴스가 죽거나 자리에서 내려간 경우) fn이 돌려준 cleanup을 저장하지 않고 즉시 소진합니다(인스턴스 죽음이 원인이면 dying = true). 전에는 그 cleanup이 아무도 소진하지 않는 핸들에 남아 조용한 영구 누수였습니다. fn 안 yield는 여전히 정의되지 않은 동작이지만, 가장 흔한 실수(네트워크 왕복 뒤 연결)의 결과가 “누수”에서 “정리됨”으로 바뀝니다.

  • 정적 자식 자리에 그 부모 자신이나 조상을 넣으면(src:Set(host)) 엔진의 순환 에러로 죽던 것 — q.debug가 켜져 있으면 이제 Dispatch: cannot place an Instance inside itself or one of its own descendants …로 거부합니다(꺼져 있으면 전처럼 엔진 에러 — 위 debug 층 항목)(Slot의 같은 규칙과 짝; State 자리라면 옛 자식은 거부 전에 이미 내려가 있습니다 — 위 두 거부와 같습니다).

  • Debounce/Throttle 게이트 핸들의 Flush가 던질 때(백엔드 미설치) blame이 내부 파일이던 것 — 사용자 줄로.

  • q.Claim이 자식 키 부재·클래스 불일치·디스크립터 재사용·같은 자식으로 해석되는 디스크립터 둘로 실패할 때 루트와 앞선 형제가 이미 claim된 채 남아, 키를 고친 재시도가 진짜 원인 대신 nativeClaim: Instance is already claimed by quad로 죽던 것 — 이제 트리 전체를 먼저 해석한 뒤에 루트부터 claim하고 아래에서 위로 적용하므로 그런 실패(그리고 이미 claim된 루트)는 아무것도 남기지 않습니다. 적용 단계에서 던지는 경우는 전과 같습니다.

  • q.Modifier({ __apply = … })처럼 __로 시작하는 이름을 초기 필드로 주면 조용히 만들어졌다가 나중에 Dispatch: no handler matched key __apply로 죽던 것 — 이제 생성 줄에서 Modifier: field "__apply" starts with "__" …로 거부합니다(setter 경로와 같은 규칙).

  • slot:List의 data에 배열이 아닌 테이블(id로 키잉한 맵, 디코드한 JSON 오브젝트 — 배열 부분이 빈 테이블)을 주면 에러 없이 목록이 통째로 비워지고 원소가 파괴되던 것 — 이제 Slot:List: data must be a plain array - key "…" is not an array position으로 거부하고 아무것도 건드리지 않습니다(생성자 initial과 같은 규칙). updateFn이 q.KeyGone을 돌려주면(nil 대신) 안내 문구로 거부합니다.

  • 서로를 담은 State 사슬(a:Set(b); b:Set(a), s:Set(s))을 자리에 놓으면 내부 파일을 가리키는 stack overflow로 죽던 것 — 이제 q.debug가 켜져 있으면 State: the value chain is circular …입니다(emit마다 사슬을 따라가는 검사라 debug 층 — 꺼져 있으면 전처럼 stack overflow로 끝나는 정의되지 않은 동작).

  • Roblox 기본 Deferred 시그널 모드에서, 인스턴스가 파괴된 뒤 같은 프레임 안에 같은 Effect/훅 핸들을 다른 인스턴스에 다시 놓으면(행 캐시가 핸들을 재사용하는 목록) 옛 인스턴스의 지연된 Destroying 콜백이 새 바인딩을 끊어 cleanup이 살아 있는 인스턴스에서 돌고 그 뒤 fn이 영영 다시 안 돌던 것 — 이제 더 이상 자기 것이 아닌 바인딩의 콜백은 무시됩니다.

  • 모듈에 is로 시작하는 평범한 헬퍼(q.isEmpty, q.isNonEmptyList, 인자를 무시하는 플러그인 isMobile)가 얹히면 그 뒤로 모든 D.<Class>{}가 Dispatch.drive: props must be a plain { ... } table …로 죽던 것 — 이제 브랜드 스캔은 q.BrandPredicates에 등록된 술어만 보므로 등록되지 않은 헬퍼는 무시됩니다.

  • 숫자 키 자리에 묶였던 Effect의 호스트가 파괴된 직후 같은 프레임 안에서 :Subscribe()/:WeakSubscribe()로 되살리면, 뒤늦게 배달되는 Destroying(Deferred 시그널 기본값)이 살아 있는 구독의 cleanup을 소진하고 fn을 영영 멈추게 하던 것 — 이제 되살릴 때 옛 연결을 끊어 그 콜백을 무시합니다.

  • tostring(store)가 rawset으로 넣은 숫자 키가 섞여 있으면 “attempt to compare string < number”로 던지던 것 — 이제 키를 문자열로 비교해 항상 찍힙니다.

  • Context/Provider 값을 Declaration 자리에 잘못 놓으면 “프로바이더가 초기화됐는지 확인하라”는 엉뚱한 안내가 붙던 것 — 이제 Store처럼 “어느 키에도 그대로 놓이지 않는다 — 필요한 값을 먼저 읽어 내라”는 안내가 나옵니다(전에 이 항목이 말했던 “숫자 키 자리에 두라”는 따라가면 같은 에러가 다시 나는 안내였습니다).

  • Frame { Children = slot }·Frame { Ref = ref }처럼 Slot/Ref를 문자 키 값으로 주면 “프로바이더가 초기화됐는지 확인하라”는 엉뚱한 안내가 나오던 것 — 이제 매치 실패 에러(Quad0076 … brand: …)가 “a quad value at a string key: it belongs in a numeric (array) slot”(숫자 키 자리에 두라)로 안내합니다 — 다른 quad 값(Tag 등)도 같은 문구입니다.

  • 핸들러 작성자용: q.Dispatch.process에 체인 길이보다 큰 index를 주면 어떤 철거도 닿지 않는 슬롯이 남고 체인 기록까지 풀리던 것 — 이제 index {n} would leave a gap in the chain …으로 거부. process/retractFrom의 nil 키, addHandler의 NaN/무한 priority, newMapperClass의 비문자열 이름도 각각 즉시 거부하고, 같은 핸들러 테이블을 두 번 등록하면 한 번만 등록됩니다. q.Dispatch.getHandler도 같은 검사를 받아, inst에 nil을 넘기면 다른 파일을 가리키는 VM 에러 대신 process와 같은 quad 메시지로 거부합니다.

  • q:RunInit(nil)/RunInit(5)가 내부 파일 줄로 죽던 것 — RunInit: initFn must be a function ….

  • 숫자 키 자리에서 내려가는 Ref의 :Wait() 대기자가 그 자리에서 던지면(ref:Wait():Unwrap()이 철거의 nil에 깨어남) 같은 Ref를 되꽂아도 영영 채워지지 않던 것 — 지금은 되꽂으면 채워집니다(대기자가 던진 뒤의 동작이라 관측이지 계약은 아닙니다).

  • state:Apply(modifier)처럼 Modifier를 Apply 팩토리 자리에 넣으면 조용히 통과해 __apply 필드가 박힌 Modifier가 만들어지고 한참 뒤 Dispatch: no handler matched key __apply로 죽던 것 — 이제 Apply가 다른 비팩토리 값과 같은 문구로 그 자리에서 거부합니다(Modifier는 __로 시작하는 이름에 setter를 만들지 않습니다).

  • q.Operator.Clamp의 두 경계가 엇갈리거나(min > max) NaN이면 내부 파일을 가리키는 math.clamp 에러가 나던 것 — 이제 읽는 줄에서 Operator.Clamp: min must be <= max and neither NaN …입니다. q.Operator.Indexed에 State를 키로 주면 영원히 nil만 읽던 것 — 이제 팩토리 줄에서 거부합니다(키는 반응형이 아닙니다).

  • 프로퍼티 자리의 체인을 철거해도(숏핸드 UICorner = …를 nil로 내려 관리 자식을 파괴할 때, q.Dispatch.retractFrom) 진행 중인 엔진 Tween이 취소되지 않고 그 키의 Tween 기록이 남아, 파괴된 자식을 향해 Tween이 돌고 철거 뒤 같은 목표값의 Tween을 다시 선언하면 통째로 무시되던 것 — 이제 철거 때 진행 중이던 Tween을 취소합니다. “첫 설정은 스냅” 규칙의 기준은 quad가 그 인스턴스의 그 프로퍼티를 처음 쓰는가이고, 철거나 자리의 값 종류 변경(plain 값 ↔ State, q.None 경유)은 그 기록을 지우지 않습니다 — 그 뒤의 Tween은 애니메이션합니다. 철거 순간 Tween이 돌고 있었다면 그 목표값만 잊어 같은 목표를 다시 선언해도 재생됩니다(프로퍼티가 중간값에 멈춰 있으므로). 값 교체 때의 동작(Override)은 그대로입니다 — 한 가지: 자리의 값 종류가 바뀌는 교체가 Tween 도중에 일어나면 새 Tween의 Override = "Finish"는 옛 목표로 스냅하지 않고 중간값에서 이어갑니다.

  • q.OnDestroyed(fn)을 State 자리에 담아 q.None으로 뺐을 때 인스턴스가 살아 있는데 fn이 돌고, 되꽂은 뒤 파괴하면 통틀어 두 번 돌던 것 — 이제 죽을 때 정확히 한 번만 돕니다(자리를 떠날 때는 돌지 않고, 뺀 채로 죽으면 돌지 않습니다).

  • 핸들러 작성자용: q.Dispatch.process(inst, 0, child, 1)처럼 양의 정수가 아닌 숫자 키로 자식·Slot을 직접 넣으면 전에는 소유권 자리가 먼저 잡힌 뒤 위치 검사에서 던져 그 값이 영영 “already mounted”로 남았습니다 — 이제 아무것도 남기지 않고 거부합니다 — Slot도 Instance 자식도 그 핸들러가 그런 키에 매치되지 않아 Dispatch: no handler matched key …(q.Dispatch.drive는 원래 거부했습니다).

  • Effect의 cleanup 안에서 의존성을 :Set하면 뒤따르는 fn이 그 값을 읽고도 (cleanup, fn) 사이클이 같은 입력으로 한 번 더 돌던 것 — 이제 한 사이클입니다. fn 안의 :Set이 한 번 더 돌게 하는 것은 그대로입니다.

  • Debounce/Throttle의 Time이 State일 때 그 값이 잘못돼 신호가 창을 못 열면(음수·문자열, 또는 시간 op가 없는 백엔드) 게이트가 “막힘 상태인데 타이머 없음”으로 굳어 다음 신호까지 통지가 멈추던 것 — 창을 먼저 잡고 상태를 바꾸도록 순서를 고쳐, 그 :Set 줄에서 던지고 게이트는 그대로 둡니다. MaxTime도 신호가 들어올 때 상태를 바꾸기 전에 읽어, 잘못된 값이면 그 :Set 줄에서 던집니다. 던진 뒤의 게이트 상태는 관측이지 계약이 아닙니다 — 지금은 새 창은 열리지 않고, 그 신호는 leading이면 이미 통과했고 아니면 게이트의 보류분에 남아 다음 통과에 실려 나갑니다. 창 끝 통과 뒤나 Flush 뒤 다시 여는 창은 전처럼 그때 Time을 읽으므로, 그 사이 값이 잘못되면 타이머 콜백이나 그 Flush 줄에서 던집니다(그 뒤 상태도 관측 — 계약 아님).

  • Claim의 매퍼 키가 트리에 없을 때 내부 파일 이름으로 죽던 것 — Claim: no child matched key … under …로 키를 말합니다. 이름은 맞는데 클래스가 다른 자식(M.TextLabel("Kid")인데 실제로는 ImageLabel)도 이제 같은 에러입니다 — 전에는 props가 겹치면 조용히 통과해 잘못된 타입의 핸들을 돌려줬습니다(하위 클래스는 IsA로 통과). BREAKING(프로바이더 작성자) — nativeFindChild(inst, key, className?)에 셋째 인자가 생겼습니다. 그 클래스가 아닌 자식이면 nil을 돌려주세요.

  • Modifier 초기 필드에 함수를 넣었을 때의 안내가 이벤트 핸들러를 변환 함수 자리로 이끌던 것 — 이벤트는 Modifier에 두지 않고 선언의 인라인 키나 State로 넣으라고 안내합니다.

  • 다른 quad 모듈 인스턴스가 만든 Slot을 Slot의 원소 자리(Add/생성자/Replace/Extract/Splice, State에 담아서, :List의 반환값으로)에 넣거나 q.dispose에 넘기면 이제 거부합니다 — 전에는 통과해 다른 인스턴스의 트리를 조용히 망가뜨릴 수 있었습니다(같은 자리의 State도 같은 문구로 거부합니다; State에 담은 것은 그 값을 정착시킬 때 — 마운트·값 변경 때 — 거부합니다).

  • q.Blocker()를 __apply로 State가 아닌 값에 걸면 내부 에러가 나던 것 — Blocker: Apply target must be a State ….

  • Animate가 건 State에 Tween이나 State를 넣었을 때 나는 검증 에러가 quad 내부 줄(Animate.luau)을 가리키던 것 — 호출한 줄을 가리킵니다.

  • Observer·Compute 같은 콜백 안에서 부른 q.Claim(...)의 인자·모양 검사 에러가 그 q.Claim 줄이 아니라 콜백을 깨운 바깥 줄(:Set 등)을 가리키던 것 — 이제 다른 인자 검증처럼 q.Claim을 부른 줄을 가리킵니다.

  • 인자 모양을 검사하지 않던 입구에 검사를 넣었습니다(전부 호출한 줄을 가리킵니다): Dispatch.process/retractFrom의 index(양의 정수), Modifier:Apply의 factory(함수), slot:List/:Single의 opts(테이블 — 문자열은 조용히 무시됐습니다), AddPlugin/UseProvider의 인자(함수)와 반환값(확장 테이블). UseProvider(nil)이 아무 일도 없이 성공하던 것도 이제 에러입니다.

  • 검증에 실패한 CRUD 호출(Add(nil) 등)이 Slot을 수동 모드로 표시해 뒤의 :List가 “CRUD를 썼다”고 거부하던 것 — 실제로 바꾼 호출만 표시합니다.

  • Replace(i, slot:Get(i))를 하면 “같은 원소가 두 번”이라던 것 — “이미 이 Slot에 있다(두 번째 마운트), Move/Swap을 쓰라”로 바르게 말합니다. keyFn이 NaN을 돌려주면 nil과 같은 도메인 에러입니다.

  • strict 타입 검사가 조용히 꺼져 있던 자리 둘을 고쳤습니다: q.Debounce/q.Throttle 옵션의 Time·MaxTime(아무 값이나 통과했습니다)과 q.Operator의 계산 함수들이 돌려주는 타입(s:Apply(q.Operator.Sum(1)) 결과가 무엇에든 대입됐습니다). 원인은 타입 선언 순서였고 런타임 동작은 같습니다. 이제 Time = "x" 같은 값은 타입 에러가 나며, 옵션의 State 자리는 StateMarker<number>로 선언됩니다(진짜 State<number>를 그대로 넘기면 됩니다).

3.4.0

Added

  • q.NewModule() — 모듈 인스턴스를 새로 만드는 함수(3.3의 q.New() 자리 — 아래 Changed).
  • q.Raw — 백엔드가 타입드 표면을 짓는 base 원재료: q.Raw.Modifier(3.3의 q.Modifier 묶음), q.Raw.Spread, q.Raw.Mapper(3.3의 q.newMapperClass).
  • Spread — 요소 테이블처럼 쓴 테이블을 요소의 숫자 키 자리에 펼쳐 넣는 일회용 묶음(quad-roblox에서는 q.Spread "Frame" {…} — 아래 항목, base 원재료는 q.Raw.Spread {…}). 같은 키는 먼저 자리 잡은 쪽이 이기고(인라인 키가 맨 앞), Modifier보다 앞섭니다. 안에 놓인 Spread는 만들 때 펴지고, spread:Append(other)는 다른 Spread만 받아 끝에 더하며 아무것도 돌려주지 않습니다. 요소에 놓이거나 다른 Spread에 펴진 Spread는 잠겨 더 이상 :Append할 수 없습니다. q.debug이면 앞선 Spread에 밀려난 함수 값을 알립니다.
  • quad-roblox: 클래스를 먼저 받는 생성자 — q.Elem "Frame" {…}, q.Modifier "Frame" (), q.Spread "Frame" {…}, q.Mapper "Frame" (key) {…}, q.OnChange "Frame" "Text" (fn), q.Out "Frame" "Text" (src), 새 q.OnEvent "Frame" "MouseEnter" (fn)(같은 이벤트를 여러 번 걸 수 있는 배열부 디스크립터), 그리고 자기 클래스 맵으로 같은 표면을 받는 q.ForClasses<<Map>>(). 타입은 생성 파일이 아니라 여러분의 타입 검사기가 가진 Roblox 클래스 타입에서 타입 함수가 계산합니다 — 새 타입 솔버가 필요하고, 3.3에서 필수였던 한도 플래그 셋은 이제 권장입니다(기본 한도에서도 대부분 검사되지만, 조상 클래스의 Modifier<GuiObject> 같은 타입을 주석으로 단 자리는 “too complex”가 날 수 있습니다 — 설치 문서).
  • quad-roblox: 클래스를 인자로 받는 타입 이름 — Props<C>·PropsHashPart<C>·PropsArrayItem<C>·MapperProps<C>·ElemCtor<C>·PropertyMap<C>·Modifier<C>·ModifierInto<C>·Spread<C>·SpreadInto<C>·OnChangeDescriptor<K>·OnEventDescriptor<K>·ModifierField<T>·ModifierFieldOut<T>.
  • Quad0281 — q.OnChange·q.Out·q.OnEvent의 클래스 단계나 이름 단계에 인자를 둘 이상 넘기면(3.3의 q.OnChange("Text", fn) 꼴, 또는 q.OnChange "TextLabel" ("Text", fn)) 그 자리에서 알려 줍니다.

Changed

  • BREAKING quad-roblox: q.Declaration(D.Frame {…}·D.New·D.Modifier.<Class>·D.Mapper.<Class>·D.Mapper.Root)이 없어졌습니다 — q.Elem "Frame" {…}·q.Modifier "Frame" ()·q.Mapper "Frame" (key) {…}·q.MapperRoot를 쓰세요. scripts/migrate-3.4.py <폴더>가 대부분을 기계로 옮깁니다(--check로 미리 보기, 못 옮긴 자리는 보고).
  • BREAKING q.New() → q.NewModule(), q.Modifier(...)(타입 없는 base) → q.Raw.Modifier(...)(q.Modifier는 이제 q.Modifier "Frame" 꼴의 타입드 생성자), q.newMapperClass(name) → q.Raw.Mapper(name). 옮기는 법: 이름만 바꾸면 됩니다(변환 스크립트가 합니다).
  • BREAKING quad-roblox: q.OnChange(name, fn)·q.Out(name, src) → q.OnChange "Class" "Name" (fn)·q.Out "Class" "Name" (src). 클래스를 먼저 받아 그 클래스의 타입으로 검사합니다(3.3에서 클래스마다 타입이 다른 이름 여섯이 any였던 것과 q.Out이 그 이름에서 올바른 Source를 거부하던 것이 함께 풀립니다). 옮기는 법: 놓을 요소의 클래스를 앞에 적으세요.
  • BREAKING quad-roblox 타입: 클래스별 이름(FrameModifier·IntoFrame·FrameElem)과 생성 모듈의 이름(Declaration·DeclarationMapper·PropTypes·OnChangeFn·OutFn·Field<T>·FieldOut<T>), 자식 자리 유니언 NewChild가 없어졌습니다 — Modifier<Frame>·ModifierInto<Frame>·ModifierField<T>·PropsArrayItem<Frame>처럼 클래스를 인자로 받는 이름이 그 자리입니다. quad-types의 Field<T>/FieldOut<T>도 ModifierField<T>/ModifierFieldOut<T>가 됐습니다. quad-roblox 루트의 OnChangeDescriptor는 이름을 인자로 받는 OnChangeDescriptor<K>가 됐습니다(옮기는 법: 이름을 모르는 자리는 OnChangeDescriptor<string>).
  • BREAKING quad-roblox 타입: 컴포넌트가 Modifier를 받는 자리 ModifierInto<C>는 메소드 없는 마커입니다 — 3.3의 Into<Class>처럼 AsTextButton 메소드를 가진 값이면 무엇이든 받는 인터페이스가 아니어서, 직접 만든 커스텀 클래스 Modifier(q.Raw.Modifier.TypedFactory)는 그 자리에 바로 들어가지 않습니다. 옮기는 법: 넘기는 쪽에서 :AsTextButton()으로 내려서 넘기세요.
  • BREAKING quad-roblox 타입: 요소의 배열부가 무타입 base Modifier(q.Raw.Modifier(...), 3.3의 q.Modifier(...))를 받지 않습니다 — 필드가 하나도 검사되지 않는 자리라서입니다(런타임은 그대로 동작). 옮기는 법: 놓을 요소의 클래스로 q.Modifier "Frame" {…}처럼 만드세요(scripts/migrate-3.4.py는 그런 자리를 고치지 않고 보고합니다). 같은 이유로 q.Raw.Spread {…}도 받지 않습니다 — q.Spread "Frame" {…}를 쓰세요.
  • quad-roblox 타입: Modifier 변환 함수의 old와 mod:Peek의 결과에서 State·Tween 팔이 읽기 뷰(State는 Get만, Tween은 Value만)가 됐습니다 — 다른 메소드가 필요하면 q.isState(old)로 확인한 뒤 캐스트하세요.
  • Quad0026·Quad0029·Quad0034·Quad0106·Quad0108·Quad0219·Quad0241·Quad0063·Quad0264의 메시지가 새 이름(q.Elem·q.Raw.Mapper·q.NewModule())을 말하고, Quad0224(q.Out에 Source가 아닌 값)는 커리 꼴에 맞게 “second argument” 대신 "Text" needs a Source to write back into로 말합니다.

Fixed

  • slot:Lazy(fn)의 fn이 q.None을 돌려주는 것을 strict 타입 검사가 거부하던 것 — 런타임은 nil처럼 빈 내용으로 받는데 타입이 막았습니다. 이제 fn: () -> (SlotElement<T> | None)?입니다.
  • slot:Lazy의 fn이 이미 다른 자리에 마운트된 원소를 돌려줄 때 나는 Quad0156의 설명이 :List의 updateFn·State 원인만 말하던 것 — :Lazy 원인도 적습니다.
  • Quad0272(slot:Lazy의 fn이 값을 둘 이상 돌려줌)가 던진 뒤 fn이 만든 원소가 정리되지 않고 남는다는 것을 말하지 않던 것 — 메시지와 문서가 그 사실을 적습니다(동작은 그대로: quad는 사용자 실수로 깨진 자리를 대신 치우지 않습니다).

3.5.0

Added

  • q:GetOrCreate(key, build) — 모듈 인스턴스마다의 결과 캐시. 처음 부를 때 build(q)를 돌려 값을 기억하고, 같은 열쇠로 다시 부르면 그 값을 돌려줍니다. 컴포넌트 킷처럼 q를 넘겨받아 쓰는 코드가 모듈을 q의 팩토리로 만들 때 씁니다(require("./Common")(q)가 같은 q에서 한 벌만 만들어짐). 캐시는 인스턴스와 함께 수거되고, 만드는 중인 열쇠를 다시 부르면 에러입니다.
  • q:AssertProvider(name, versionPattern) — 받은 q에 필요한 백엔드가 맞는 버전으로 설치돼 있는지 확인하고, 아니면 “설정 모듈에서 UseProvider를 먼저”라고 알리며 던집니다. 프로바이더 함수가 아니라 이름과 버전으로 비교하므로 패키지 사본이 달라도 같은 백엔드로 알아봅니다.
  • q.Backend.provider — 프로바이더가 자기 이름과 버전({ name, version })을 적는 자리. quad-roblox는 설치 때 { name = "quad-roblox", version = <자기 버전> }을 적습니다. 직접 만든 프로바이더도 AssertProvider로 확인받으려면 여기에 적으세요.
  • QuadTypes.SlotCtx<Item, UD, T> — slot:List·slot:Single의 updateFn이 받는 ctx의 타입 이름(항목, 사용자 데이터, 원소 타입). 파일마다 같은 레코드를 다시 적던 자리를 type RowCtx = QuadTypes.SlotCtx<Row, nil, Instance> 한 줄로 쓸 수 있습니다.

Changed

  • quad(quad-base·quad-roblox)의 q.debug 진단 줄이 모두 [quad debug] 로 시작합니다 — Studio 출력에서 자기 print와 구분됩니다. 진단 문구를 통째로 비교하던 테스트는 접두를 넣거나 부분 일치로 바꾸세요.
  • Quad0076: 숫자 키 자리에 함수가 오면({ q.Elem "Frame" }처럼 호출을 빠뜨린 경우) 프로바이더 설정을 의심하라는 꼬리 대신 “호출하지 않은 생성자나 컴포넌트”를 묻는 꼬리를 붙입니다.
  • Quad0109·Quad0110·Quad0113(Observer): 메시지가 “콜백 안에서”뿐 아니라 “콜백이 앞서 던진 뒤”인 경우도 말합니다 — 던진 콜백은 실행 중 표시를 남깁니다.

4.0.0 Latest

Changed

  • 3.x 안정화 구간을 닫습니다. 이제 호환이 깨지는 변경은 메이저 버전에만 싣습니다 — 설치 줄은 ~3.5.0 대신 ^4.0.0으로 적으세요. q:AssertProvider나 커스텀 프로바이더에 3.x 패턴을 적어 둔 코드는 Quad0289·Quad0228로 막히니 4.0^.0^류로 바꾸세요. quad_error와 type_version_check는 이 릴리즈 뒤로 각자의 번호로 올라갑니다. 공식 킷 둘(qwreey/quad_kit_roblox_devui·qwreey/quad_kit_roblox_material)이 4.0.0으로 처음 게시됩니다.
  • BREAKING 에러 번호를 실수 단위로 합쳤습니다 — 같은 규칙을 어기면 경로·q.debug·Effect/Observer 차이와 상관없이 같은 번호입니다. 이제 어떤 실수가 어느 번호로 보고되는지는 계약이고(바꾸면 메이저), 메시지 문구는 계약이 아닙니다. 옮기는 법: 번호로 분기하던 코드가 있으면 아래 표대로 새 번호로 바꾸세요. 폐기된 번호는 레퍼런스 각 페이지 끝의 접힌 목록에 남아 새 번호를 가리키고 다른 뜻으로 재사용되지 않습니다.
폐기 새 번호
Quad0003 · Quad0006 · Quad0013 Quad0001
Quad0005 · Quad0009 Quad0004
Quad0043 · Quad0123 · Quad0252 Quad0018
Quad0158 · Quad0162 Quad0020
Quad0064 Quad0057
Quad0264 Quad0063
Quad0097 · Quad0116 · Quad0137 · Quad0274 · Quad0277 Quad0068
Quad0109 Quad0087
Quad0090 · Quad0110 · Quad0113 Quad0088
Quad0091 · Quad0111 · Quad0114 Quad0089
Quad0112 Quad0092
Quad0115 Quad0093
Quad0164 · Quad0165 · Quad0242 Quad0140
Quad0151 Quad0142
Quad0160 · Quad0172 Quad0156
Quad0171 Quad0170
Quad0182 · Quad0185 Quad0181
Quad0204 · Quad0237 Quad0200
Quad0211 Quad0209
Quad0241 Quad0219
Quad0231 · Quad0234 · Quad0235 Quad0230
Quad0233 Quad0232
Quad0245 Quad0244
  • Tween의 콜백 셋(Started·Completed·Cancelled), tween:Wrapped와 tween:Mapped의 wrap, q.ForClasses와 타입 함수가 계산하는 표면의 세부를 unstable로 표시했습니다 — SemVer 보증 밖이고, 바뀌면 이 파일의 Changed에 (unstable)을 달아 알립니다. 동작 변경은 없습니다.
  • Completed의 정의를 문서에 못 박았습니다 — 끝났는지는 Completed가 나왔는가로만 정하고, 프로퍼티가 목표에 닿았는지는 묻지 않습니다. Completed 전에 교체되거나 철거되면 언제나 Cancelled입니다. 그래서 엔진이 목표에 닿았지만 통지가 아직인 트윈을 교체하면 전에는 Completed였고 이제 Cancelled입니다(unstable — 실측에서 사용자 코드가 그 사이에 끼어든 경우는 없었습니다).