관리자 환경설정에서 미입금 자동취소 기한을 저장하면 HTML number 입력의 문자열이 그대로 영속되고, Carbon 3.13 의 strict 타입 경계에서 TypeError 가 발생해 무통장입금·가상계좌 주문이 전면 실패했다(공개 제보). 한 곳만 막으면 다른 저장 경로가 남으므로 저장·조회·소비·모델 네 계층에 각각 방어를 두고, 저장소 전역에서 같은 형태가 더 없음을 audit 룰로 확정했다. 같은 계획서 수행 중 드러난 인접 결함도 함께 해소했다. - 쿠폰 수정 경로로 (발급일 기준, 일수 NULL) 조합이 저장되어 발급 즉시 만료된 쿠폰이 조용히 나가던 문제 — 저장·발급 양쪽에서 차단 - 리뷰 작성 기한을 화면과 저장 판정이 서로 다른 기준으로 계산하던 문제 - 결제 플러그인이 PaymentMethodEnum 과 다른 어휘로 지원 결제수단을 선언해 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제 엔진에서는 `_localInit` 적용 여부를 전역 해시로만 판정해 인스턴스별 저장소가 리셋되지 않던 결함(engine-v1.54.5)과, 제거 대상이 없어도 상태 갱신·캐시 무효화가 상시 발생하던 판정 결함(engine-v1.54.6)을 고쳤다. Playwright MCP 정밀 점검(T1~T11)에서 설정이 빈 문자열로 남은 사이트의 주문서 입금기한 안내가 미치환 플레이스홀더를 노출하는 것을 실측해, 화면 폴백을 서버와 같은 규칙으로 맞췄다.
195 lines
8.0 KiB
TypeScript
195 lines
8.0 KiB
TypeScript
/**
|
|
* `_localInit` 슬롯 관리 (생산부 ↔ 소비부 공용)
|
|
*
|
|
* `_localInit` 은 데이터소스의 `initLocal` 옵션이 만든 로컬 상태 초기화 payload 를
|
|
* 렌더 컨텍스트에 실어 나르는 단일 키다. 생산부(TemplateApp → updateTemplateData)와
|
|
* 소비부(DynamicRenderer 의 useEffect)가 이 키 하나를 공유한다.
|
|
*
|
|
* 문제: 소비는 React commit 이후(useEffect)에 일어난다. progressive 데이터소스가
|
|
* 둘 이상이면 각자 독립적으로 `updateTemplateData({ _localInit })` 를 호출하는데,
|
|
* 두 호출이 같은 commit 사이에 들어오면 나중 payload 가 슬롯을 통째로 교체하여
|
|
* 먼저 도착한 소스의 초기값이 **한 번도 관측되지 않은 채** 사라진다.
|
|
*
|
|
* 해결: 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯에 한해 누적 병합한다.
|
|
* 이미 소비된 슬롯은 지금까지처럼 교체한다 — 그래야 사용자가 화면을 쓰는 도중
|
|
* `refetchDataSource` 가 발생해도 소비가 끝난 과거 payload 가 재적용되어
|
|
* 폼 편집 결과를 되돌리는 일이 없다.
|
|
*
|
|
* 소비 여부는 소비부(DynamicRenderer)가 관측한 슬롯의 **참조**를 기록해 판정한다.
|
|
* 해시 계산식을 생산부에 복제하지 않으므로 두 곳의 판정 기준이 어긋날 수 없다.
|
|
*
|
|
* @since engine-v1.52.2
|
|
*/
|
|
|
|
/** `_localInit` 전역 추적 레지스트리 */
|
|
export interface LocalInitTracking {
|
|
/** 마지막으로 적용된 payload 의 추적 키 (`해시:_forceLocalInit`) */
|
|
hash: string;
|
|
/** 마지막으로 적용된 `_forceLocalInit` 타임스탬프 */
|
|
timestamp?: number;
|
|
/**
|
|
* 마지막으로 렌더러가 관측한 슬롯 객체의 참조.
|
|
* 생산부가 이 참조로 "소비 완료" 여부를 판정한다.
|
|
*/
|
|
consumed?: unknown;
|
|
}
|
|
|
|
/** window 전역 키 (기존 이름 유지 — 디버깅 스크립트 호환) */
|
|
const TRACKING_KEY = '__g7LocalInitTracking';
|
|
|
|
/**
|
|
* `_localInit` 전역 추적 레지스트리를 반환합니다. 없으면 생성합니다.
|
|
*
|
|
* @returns 전역 추적 레지스트리
|
|
*/
|
|
export function getLocalInitTracking(): LocalInitTracking {
|
|
const target = globalThis as Record<string, any>;
|
|
|
|
if (!target[TRACKING_KEY]) {
|
|
target[TRACKING_KEY] = { hash: '', timestamp: undefined, consumed: undefined };
|
|
}
|
|
|
|
return target[TRACKING_KEY] as LocalInitTracking;
|
|
}
|
|
|
|
/**
|
|
* 추적 레지스트리를 초기화합니다.
|
|
*
|
|
* SPA 네비게이션으로 레이아웃이 바뀌면 `_local` 이 통째로 리셋되므로
|
|
* (`TemplateApp.loadRoute` 의 레이아웃 전환 감지), 추적 해시도 함께 비워야
|
|
* 새 레이아웃의 `_localInit` 이 이전 레이아웃과 payload 가 우연히 같더라도
|
|
* 정상 적용된다.
|
|
*
|
|
* @returns void
|
|
*/
|
|
export function resetLocalInitTracking(): void {
|
|
const target = globalThis as Record<string, any>;
|
|
target[TRACKING_KEY] = { hash: '', timestamp: undefined, consumed: undefined };
|
|
}
|
|
|
|
/**
|
|
* 렌더러가 관측한 `_localInit` 슬롯을 "소비 완료"로 표시합니다.
|
|
*
|
|
* 적용(shouldApply)/건너뜀(동일 해시) 여부와 무관하게 호출한다.
|
|
* 건너뛴 경우에도 그 payload 는 이미 `_local` 에 반영되어 있으므로 소비된 것이다.
|
|
*
|
|
* @param slot 관측한 `_localInit` 슬롯 객체
|
|
* @returns void
|
|
*/
|
|
export function markLocalInitConsumed(slot: unknown): void {
|
|
getLocalInitTracking().consumed = slot;
|
|
}
|
|
|
|
/**
|
|
* 해당 슬롯이 이미 렌더러에게 관측되었는지 판정합니다.
|
|
*
|
|
* @param slot 판정할 `_localInit` 슬롯 객체
|
|
* @returns 관측 완료 시 true
|
|
*/
|
|
export function isLocalInitConsumed(slot: unknown): boolean {
|
|
return getLocalInitTracking().consumed === slot;
|
|
}
|
|
|
|
/**
|
|
* `_localInit` payload 에 대해 한 렌더러 인스턴스가 취할 동작.
|
|
*
|
|
* - `apply`: 이 payload 를 아직 아무도 적용하지 않았다 → 저장소 A·B 를 모두 갱신
|
|
* - `prune`: 다른 인스턴스가 이미 적용했다 → **이 인스턴스의 저장소 A 에서 payload 키 공간만 제거**
|
|
* - `skip`: 이 인스턴스가 이미 처리했다 → 아무것도 하지 않음
|
|
*
|
|
* @since engine-v1.54.7
|
|
*/
|
|
export type LocalInitAction = 'apply' | 'prune' | 'skip';
|
|
|
|
/**
|
|
* `_localInit` payload 에 대해 이 렌더러 인스턴스가 취할 동작을 판정합니다.
|
|
*
|
|
* 전역 해시(`__g7LocalInitTracking.hash`)는 "이 payload 를 처음 관측했는가"만 판정한다.
|
|
* 그러나 리셋 대상인 `localDynamicState`(저장소 A)는 **인스턴스별**이므로, 전역 1회 소비만으로
|
|
* 끝내면 나머지 루트 렌더러의 저장소 A 에 stale leaf 가 영구 잔존한다. 그 잔존값은
|
|
* `deepMergeState(dataContext._local, dynamicState)` 에서 A 우선이라 신선한 저장소 B 를 덮는다.
|
|
*
|
|
* 건너뛴 인스턴스가 payload 를 **다시 적용**해서는 안 된다 — 늦게 마운트된 인스턴스가 이미
|
|
* 소비된 과거 payload 를 저장소 B 에 되쓰면서 그 사이의 사용자 편집을 되돌린다
|
|
* (`mergeLocalInitSlot` 의 `consumed → 교체` 규칙이 막고 있는 바로 그 회귀). 그래서 `prune` 은
|
|
* 값을 도입하지 않고 제거만 한다. 제거는 stale 값을 되살릴 수 없고, 제거된 자리에는 이미
|
|
* 갱신된 저장소 B 가 그대로 비쳐 보인다.
|
|
*
|
|
* 배경: `troubleshooting-state-advanced.md` 사례 13(engine-v1.18.3)이 세운
|
|
* "동일 commit 내 복수 root 의 실행 순서에 의존 금지" 규칙의 미적용 구간이었다.
|
|
*
|
|
* @param params.globalTrackedKey 전역 레지스트리에 기록된 추적 키
|
|
* @param params.instanceHandledKey 이 인스턴스가 마지막으로 처리한 추적 키 (미처리 시 null)
|
|
* @param params.trackingKey 이번 payload 의 추적 키 (`해시:_forceLocalInit`)
|
|
* @returns 이 인스턴스가 취할 동작
|
|
*/
|
|
export function resolveLocalInitAction(params: {
|
|
globalTrackedKey: string;
|
|
instanceHandledKey: string | null;
|
|
trackingKey: string;
|
|
}): LocalInitAction {
|
|
if (params.globalTrackedKey !== params.trackingKey) {
|
|
return 'apply';
|
|
}
|
|
|
|
return params.instanceHandledKey === params.trackingKey ? 'skip' : 'prune';
|
|
}
|
|
|
|
/**
|
|
* `_forceLocalInit` 타임스탬프를 병합합니다.
|
|
*
|
|
* `refetchOnMount: true` 인 소스만 이 값을 갖는다. 두 payload 를 합칠 때
|
|
* 어느 한쪽에만 있으면 그 값을 보존해야 강제 초기화가 유실되지 않는다.
|
|
*
|
|
* @param prev 기존 슬롯의 타임스탬프
|
|
* @param next 새 payload 의 타임스탬프
|
|
* @returns 병합된 타임스탬프 (양쪽 모두 없으면 undefined)
|
|
*/
|
|
function mergeForceFlag(prev: unknown, next: unknown): unknown {
|
|
if (typeof prev === 'number' && typeof next === 'number') {
|
|
return Math.max(prev, next);
|
|
}
|
|
|
|
return next ?? prev;
|
|
}
|
|
|
|
/**
|
|
* `_localInit` 슬롯에 새 payload 를 반영합니다.
|
|
*
|
|
* - 새 payload 가 없으면 기존 슬롯을 그대로 반환 (참조 동일성 유지 — 소비부 effect 재발화 방지)
|
|
* - 기존 슬롯이 없거나 이미 소비되었으면 새 payload 로 교체
|
|
* - 기존 슬롯이 아직 소비되지 않았으면 누적 병합 (나중 payload 우선)
|
|
*
|
|
* @param prev 현재 데이터 컨텍스트의 `_localInit` 슬롯
|
|
* @param next 이번 업데이트가 실어 온 `_localInit` payload
|
|
* @returns 슬롯에 담길 값
|
|
*/
|
|
export function mergeLocalInitSlot(prev: unknown, next: unknown): unknown {
|
|
if (next === undefined) {
|
|
return prev;
|
|
}
|
|
|
|
const isMergeable =
|
|
prev !== null &&
|
|
typeof prev === 'object' &&
|
|
next !== null &&
|
|
typeof next === 'object' &&
|
|
!isLocalInitConsumed(prev);
|
|
|
|
if (!isMergeable) {
|
|
return next;
|
|
}
|
|
|
|
const { _forceLocalInit: prevForce, ...prevData } = prev as Record<string, any>;
|
|
const { _forceLocalInit: nextForce, ...nextData } = next as Record<string, any>;
|
|
|
|
const merged: Record<string, any> = { ...prevData, ...nextData };
|
|
const force = mergeForceFlag(prevForce, nextForce);
|
|
|
|
if (force !== undefined) {
|
|
merged._forceLocalInit = force;
|
|
}
|
|
|
|
return merged;
|
|
}
|