fix(core): 다중 progressive 데이터소스의 initLocal 유실 수정

_localInit 은 단일 슬롯이고 updateTemplateData 가 얕은 스프레드로 교체했다.
소비부(DynamicRenderer useEffect)는 React commit 이후에 실행되므로,
initLocal 을 가진 progressive 소스가 둘 이상이면 두 payload 가 같은 commit
사이에 도착할 때 먼저 온 것이 한 번도 관측되지 않고 사라졌다.

무조건 누적 병합은 refetchDataSource 시 소비가 끝난 payload 를 재적용해
사용자 폼 편집을 되돌린다. 그래서 아직 관측되지 않은 슬롯만 병합하고,
관측된 슬롯은 종전대로 교체한다. 소비 여부는 소비부가 기록한 슬롯 참조로
판정해 해시 계산식을 생산부에 복제하지 않는다.

레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 비운다.

검증: CPU 6배 스로틀로 경합 창을 강제로 열어 수정 전 0/8 → 수정 후 8/8.
동반: template-engine.test.ts 의 vi.mock 이 공유 런타임 export 를 빠뜨려
스위트가 로드되지 않던 것과, 코어 spec 2개가 없는 fixture 를 import 해
playwright 수집이 실패하던 것을 함께 복구했다.
This commit is contained in:
HeuJung
2026-07-10 19:27:27 +09:00
parent 051ed1e528
commit 12968b76ca
13 changed files with 611 additions and 25 deletions
+1
View File
@@ -9,6 +9,7 @@
### Fixed
- 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다.
- 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다.
- 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.)
## [7.0.2] - 2026-07-08
+34
View File
@@ -185,6 +185,40 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
- 상세 설명: [`docs/extension/plugin-development.md`](../extension/plugin-development.md) "폼 상태 정합성" 섹션
- 구현 참고: [`DynamicRenderer.tsx`](../../resources/js/core/template-engine/DynamicRenderer.tsx) `performStateUpdate` 상단 주석 (~50줄)
### `_localInit` 은 단일 슬롯이 아니다 (engine-v1.52.2+)
데이터소스의 `initLocal` 이 만든 초기화 payload 는 `dataContext._localInit` 한 키로 전달된다.
생산부는 `updateTemplateData`(3개 write site), 소비부는 `DynamicRenderer` 의 `_localInit` useEffect 다.
소비는 React commit **이후**에 일어난다. progressive 데이터소스는 응답이 오는 대로 각자 독립적으로
`updateTemplateData({ _localInit })` 를 호출하므로, `initLocal` 을 가진 progressive 소스가 둘 이상이면
두 호출이 같은 commit 사이에 들어올 수 있다. 이때 슬롯을 교체하면 먼저 도착한 payload 가
한 번도 관측되지 않고 사라진다.
`_localInit` 슬롯 병합과 `__g7LocalInitTracking` 레지스트리의 단일 소유자는
[`localInitSlot.ts`](../../resources/js/core/template-engine/localInitSlot.ts) 다.
```text
❌ updateTemplateData 에서 _localInit 을 얕은 스프레드(...data)로 교체
❌ 소비 여부와 무관하게 _localInit 을 무조건 누적 병합
(소비가 끝난 payload 가 refetchDataSource 시 재적용되어 사용자 폼 편집을 되돌린다)
❌ 소비 여부 판정을 위해 해시 계산식을 생산부에 복제
(생산·소비 양쪽의 판정 기준이 어긋난다 — 슬롯 참조 비교를 쓴다)
❌ __g7LocalInitTracking 을 레이아웃 전환 시 리셋하지 않음
(_local 은 비웠는데 추적 해시가 남아 새 레이아웃의 동일 payload 가 "이미 적용됨" 으로 건너뛰어진다)
```
```text
✅ 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합 — mergeLocalInitSlot()
✅ 관측 시 슬롯 참조 기록 — markLocalInitConsumed(). 적용/건너뜀 여부와 무관하게 호출
✅ 레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 초기화 — resetLocalInitTracking()
✅ _forceLocalInit 은 한쪽에만 있으면 보존, 양쪽에 있으면 최신 타임스탬프 (refetchOnMount 강제 초기화 유실 방지)
✅ 소비부는 A(setLocalDynamicState)/B(_globalSetState) 양쪽을 갱신 — 이 대칭을 깨지 않는다
```
> 이 결함은 정상 CPU 에서 간헐적이라 놓치기 쉽다. CPU 스로틀링(6배 이상)으로 commit 을 지연시켜야
> 경합 창이 결정적으로 열린다.
### 라이프사이클 (SPA 네비게이션)
SPA navigate 시 `_local` 상태는 **레이아웃 이름 기준으로 선택적 초기화**됩니다:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -28,6 +28,7 @@ import { createLogger, Logger } from './utils/Logger';
import { webSocketManager } from './websocket/WebSocketManager';
import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig, parseBundleUrlsFromConfig } from './modules';
import { SystemBannerManager } from './template-engine/SystemBannerManager';
import { resetLocalInitTracking } from './template-engine/localInitSlot';
/**
* DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
*/
@@ -1146,6 +1147,11 @@ export class TemplateApp {
// 다른 레이아웃으로 전환 → _local 완전 초기화 (이전 레이아웃 잔존값 제거)
logger.log('_local reset due to layout change:', { from: this.currentLayoutName, to: newLayoutName });
this.globalState._local = {};
// @since engine-v1.52.2: _localInit 추적 레지스트리도 함께 초기화.
// _local 을 비웠는데 추적 해시가 남아 있으면, 새 레이아웃의 _localInit payload 가
// 이전 레이아웃과 우연히 동일할 때 "이미 적용됨"으로 오판되어 건너뛴다.
resetLocalInitTracking();
}
this.currentLayoutName = newLayoutName;
@@ -14,6 +14,7 @@ import TemplateEngine, {
getState,
} from '../template-engine';
import { ComponentRegistry } from '../template-engine/ComponentRegistry';
import { markLocalInitConsumed, resetLocalInitTracking } from '../template-engine/localInitSlot';
import { Logger } from '../utils/Logger';
/**
@@ -45,12 +46,19 @@ vi.mock('../template-engine/ComponentRegistry', () => {
});
// DataBindingEngine mock
vi.mock('../template-engine/DataBindingEngine', () => ({
DataBindingEngine: vi.fn(function(this: any) {
// G7CoreGlobals.initCoreRuntimeExports가 싱글톤 `dataBindingEngine`도 참조하므로 함께 노출
vi.mock('../template-engine/DataBindingEngine', () => {
const DataBindingEngine = vi.fn(function(this: any) {
this.bind = vi.fn();
this.unbind = vi.fn();
}),
}));
this.invalidateCacheByKeys = vi.fn();
});
return {
DataBindingEngine,
dataBindingEngine: new (DataBindingEngine as any)(),
};
});
// TranslationEngine mock
vi.mock('../template-engine/TranslationEngine', () => {
@@ -97,10 +105,13 @@ vi.mock('../template-engine/ResponsiveManager', () => ({
getMatchingKey: vi.fn(() => null),
parseRange: vi.fn(() => null),
},
BREAKPOINT_PRESETS: {},
}));
// ResponsiveContext mock
// G7CoreGlobals.initCoreRuntimeExports가 Context 객체 자체도 참조하므로 함께 노출
vi.mock('../template-engine/ResponsiveContext', () => ({
ResponsiveContext: {},
ResponsiveProvider: ({ children }: { children: React.ReactNode }) => children,
useResponsive: vi.fn(() => ({
width: 1024,
@@ -374,6 +385,8 @@ describe('TemplateEngine - updateTemplateData()', () => {
beforeEach(async () => {
destroyTemplate();
vi.clearAllMocks();
// DynamicRenderer가 모킹되어 _localInit 슬롯을 소비하지 않으므로 테스트 간 격리 필요
resetLocalInitTracking();
document.body.innerHTML = '<div id="app"></div>';
@@ -456,6 +469,53 @@ describe('TemplateEngine - updateTemplateData()', () => {
});
});
/**
* 다중 progressive 데이터소스의 _localInit 상호 덮어쓰기 회귀
*
* 증상: initLocal을 가진 progressive 데이터소스가 한 레이아웃에 둘 이상이면,
* React commit 전에 두 번의 updateTemplateData가 연달아 들어올 때
* 나중 payload가 _localInit 슬롯을 통째로 교체하여 앞선 소스의 초기값이 유실됨.
* 해결: 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯은 교체하지 않고 누적 병합.
*
* @see troubleshooting-state-global.md
*/
it('소비되지 않은 _localInit 슬롯은 후속 payload와 누적 병합되어야 함', () => {
// settings 소스 응답 (refetchOnMount → _forceLocalInit 보유)
updateTemplateData({
settings: { shipping: {} },
_localInit: { form: { name: 'shipping' }, _forceLocalInit: 1700000000000 },
});
// notification 소스 응답 (같은 commit 이전에 도착)
updateTemplateData({
notificationDefinitions: { data: [] },
_localInit: { notificationDefinitionCurrentPage: 1 },
});
const state = getState();
// 먼저 도착한 settings의 form이 살아있어야 함
expect(state.currentDataContext._localInit.form).toEqual({ name: 'shipping' });
// 나중에 도착한 notification 값도 함께 있어야 함
expect(state.currentDataContext._localInit.notificationDefinitionCurrentPage).toBe(1);
// refetchOnMount 타임스탬프가 보존되어야 함
expect(state.currentDataContext._localInit._forceLocalInit).toBe(1700000000000);
});
it('이미 소비된 _localInit 슬롯은 교체되어 stale 재적용이 없어야 함', () => {
updateTemplateData({ _localInit: { form: { name: 'shipping' } } });
// 렌더러가 슬롯을 관측 (DynamicRenderer의 _localInit useEffect가 하는 일)
markLocalInitConsumed(getState().currentDataContext._localInit);
// 사용자가 화면을 쓰는 도중 다른 소스를 refetch
updateTemplateData({ _localInit: { notificationDefinitionCurrentPage: 2 } });
const state = getState();
// 소비가 끝난 form은 재적용 대상이 아니어야 함 (폼 편집 결과 되돌림 방지)
expect(state.currentDataContext._localInit.form).toBeUndefined();
expect(state.currentDataContext._localInit.notificationDefinitionCurrentPage).toBe(2);
});
it('sync 옵션으로 업데이트해도 정상 작동해야 함', () => {
updateTemplateData({ c: 5 }, { sync: true });
+10
View File
@@ -24,6 +24,7 @@ import { DataSourceManager } from './template-engine/DataSourceManager';
import { ModalDataSourceWrapper } from './template-engine/ModalDataSourceWrapper';
import { ParentContextProvider } from './template-engine/ParentContextProvider';
import { TemplateNotFoundError } from './template-engine/TemplateEngineError';
import { mergeLocalInitSlot } from './template-engine/localInitSlot';
import { ErrorDisplay } from './template-engine/ErrorDisplay';
import { TemplateApp, initTemplateApp } from './TemplateApp';
import { createLogger } from './utils/Logger';
@@ -749,11 +750,20 @@ function updateTemplateData(data: Record<string, any>, options?: UpdateOptions):
...(data._global || {}),
};
// _localInit도 얕은 스프레드로 교체하면 안 됨 (engine-v1.52.2)
// progressive 데이터소스가 둘 이상이면 각자 독립적으로 updateTemplateData를 호출하는데,
// 소비부(DynamicRenderer의 useEffect)는 React commit 이후에 실행된다.
// 두 호출이 같은 commit 사이에 들어오면 나중 payload가 슬롯을 통째로 교체하여
// 먼저 도착한 소스의 initLocal이 한 번도 관측되지 않고 사라진다.
// → 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합한다. 상세: localInitSlot.ts
const mergedLocalInit = mergeLocalInitSlot(state.currentDataContext._localInit, data._localInit);
state.currentDataContext = {
...globalVariables, // 전역 변수 (낮은 우선순위)
...state.currentDataContext, // 기존 데이터
...data, // 새 데이터 (높은 우선순위)
_global: mergedGlobalState, // _global은 명시적으로 깊은 병합된 값 사용
_localInit: mergedLocalInit, // _localInit은 소비 전이면 누적 병합된 값 사용
};
// _local 및 _computed는 _global의 값을 canonical source로 동기화
@@ -5,6 +5,20 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.52.2] - 2026-07-10
### Fixed
#### 다중 progressive 데이터소스의 `_localInit` 상호 덮어쓰기
- `template-engine.ts::updateTemplateData()` — `_localInit` 을 얕은 스프레드(`...data`)로 교체하던 것을 **소비 여부를 가드로 둔 누적 병합**으로 바꿨다. `_localInit` 소비부(`DynamicRenderer` 의 useEffect)는 React commit 이후에 실행되는데, `initLocal` 을 가진 progressive 데이터소스가 한 레이아웃에 둘 이상이면 각 소스가 응답 순서대로 독립적으로 `updateTemplateData({ _localInit })` 를 호출한다. 두 호출이 같은 commit 사이에 들어오면 나중 payload 가 슬롯을 통째로 교체해, 먼저 도착한 소스의 `initLocal` 이 **한 번도 관측되지 않은 채** 사라졌다. `_global` 은 바로 윗줄에서 깊은 병합을 받고 있었으나 `_localInit` 에는 그 처리가 없었다.
- `localInitSlot.ts`(신규) — `_localInit` 슬롯 병합과 `__g7LocalInitTracking` 레지스트리의 단일 소유자. 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯만 누적 병합하고, 이미 관측된 슬롯은 종전대로 교체한다. 그래야 사용자가 화면을 쓰는 도중 `refetchDataSource` 가 발생해도 소비가 끝난 과거 payload 가 재적용되어 폼 편집 결과를 되돌리지 않는다. `_forceLocalInit` 은 한쪽에만 있으면 보존하고 양쪽에 있으면 최신 타임스탬프를 취한다.
- `DynamicRenderer.tsx` — `_localInit` 관측 시 그 슬롯 참조를 `markLocalInitConsumed()` 로 기록한다. 해시 계산식을 생산부에 복제하지 않으므로 생산·소비 양쪽의 판정 기준이 어긋날 수 없다. 적용(`shouldApply`)/건너뜀(동일 해시) 여부와 무관하게 기록한다 — 건너뛴 payload 도 이미 `_local` 에 반영되어 있으므로 소비된 것이다. A(`setLocalDynamicState`)/B(`_globalSetState`) 이중 저장소 갱신 대칭은 그대로 유지한다.
- `TemplateApp.ts` — SPA 레이아웃 전환으로 `_local` 을 리셋할 때 `__g7LocalInitTracking` 도 함께 초기화한다(`resetLocalInitTracking()`). 종전에는 리셋 지점이 아예 없어, 새 레이아웃의 `_localInit` payload 가 이전 레이아웃과 우연히 같으면 "이미 적용됨" 으로 오판되어 건너뛰었다.
- 부수 수정 — `init_actions` 안의 `refetchDataSource` 가 React commit 이전에 `updateTemplateData` 를 호출하는 경우(`pendingDataUpdates` 큐 경유)에도 blocking 소스의 `initLocal` 이 조용히 유실되던 잠재 결함이 함께 닫힌다.
> 사용자 영향: `initLocal` 을 가진 데이터소스가 둘 이상인 화면에 SPA 네비게이션으로 진입할 때, 먼저 도착한 데이터소스의 초기값이 간헐적으로 비어 보이던 문제가 해소된다.
## [engine-v1.52.1] - 2026-07-10
### Fixed
@@ -29,6 +29,7 @@ import { hasPipes } from './PipeRegistry';
import { RAW_PREFIX, RAW_MARKER_START, RAW_MARKER_END, RAW_PLACEHOLDER_MARKER, isRawWrapped, unwrapRaw, containsRawMarker, wrapRawDeep } from './rawMarkers';
import type { ConditionsProperty } from './helpers/ConditionEvaluator';
import { useTransitionState } from './TransitionContext';
import { getLocalInitTracking, markLocalInitConsumed } from './localInitSlot';
import { useResponsive } from './ResponsiveContext';
import { createLogger } from '../utils/Logger';
import { shallowObjectEqual } from '../hooks/useControllableState';
@@ -1318,11 +1319,7 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
// - extends 레이아웃에서 여러 컴포넌트가 동일한 _localInit을 받을 수 있음
// - 컴포넌트 ID 기반 추적은 각 컴포넌트가 독립적으로 처리하여 중복 적용됨
// - 데이터 해시만으로 추적하여, 같은 데이터는 어떤 컴포넌트에서든 한 번만 처리
const globalTrackingKey = '__g7LocalInitTracking';
if (!(window as any)[globalTrackingKey]) {
(window as any)[globalTrackingKey] = { hash: '', timestamp: undefined };
}
const tracking = (window as any)[globalTrackingKey] as { hash: string; timestamp?: number };
const tracking = getLocalInitTracking();
// 추적 키는 데이터 해시 + 타임스탬프 (컴포넌트 ID 제외)
// refetchOnMount 시 _forceLocalInit 타임스탬프가 변경되면 재적용됨
@@ -1398,6 +1395,11 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
// hash tracking에 의해 shouldApply가 false(skip)된 경우에도 갱신하여
// extendedDataContext useMemo가 정상 병합(deepMergeState)을 재개하도록 함
lastProcessedInitRef.current = dataContext._localInit;
// @since engine-v1.52.2: 이 슬롯을 관측했음을 생산부(updateTemplateData)에 알림.
// 생산부는 아직 관측되지 않은 슬롯만 누적 병합하고, 관측된 슬롯은 교체한다.
// skip(동일 해시)된 경우에도 그 payload는 이미 _local에 반영되어 있으므로 소비된 것이다.
markLocalInitConsumed(dataContext._localInit);
}
}, [dataContext._localInit, parentDataContext, bindingEngine]);
@@ -8,6 +8,13 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ActionDispatcher } from '../ActionDispatcher';
import {
getLocalInitTracking,
isLocalInitConsumed,
markLocalInitConsumed,
mergeLocalInitSlot,
resetLocalInitTracking,
} from '../localInitSlot';
import { Logger } from '../../utils/Logger';
// AuthManager mock
@@ -580,4 +587,91 @@ describe('트러블슈팅 회귀 테스트 - 데이터소스 업데이트 및
expect(keyNoForce).toContain('no-force');
});
});
describe('[사례 6] 다중 progressive 데이터소스의 _localInit 상호 덮어쓰기 (engine-v1.52.2)', () => {
/**
* 증상: initLocal을 가진 progressive 데이터소스가 한 레이아웃에 둘 이상일 때,
* SPA 네비게이션으로 진입하면 먼저 도착한 소스의 initLocal이 간헐적으로 유실됨
* (배송설정 탭: 배송가능 국가 표가 비고 "No shipping countries registered.")
* 원인: progressive 소스는 응답이 오는 대로 각자 updateTemplateData({ _localInit })를 호출하는데,
* 소비부(DynamicRenderer의 useEffect)는 React commit 이후에 실행된다.
* 두 호출이 같은 commit 사이에 들어오면 나중 payload가 _localInit 슬롯을 통째로 교체하여
* 먼저 도착한 소스의 payload는 한 번도 관측되지 않고 사라진다.
* 해결: 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯만 누적 병합.
* 이미 소비된 슬롯은 교체 (refetch 시 stale 재적용으로 폼 편집이 되돌아가는 것 방지).
*
* @see resources/js/core/template-engine/localInitSlot.ts
* @see resources/js/core/template-engine.ts - updateTemplateData
*/
beforeEach(() => {
resetLocalInitTracking();
});
afterEach(() => {
resetLocalInitTracking();
});
it('소비 전 슬롯은 후속 payload와 누적 병합되어 양쪽 키가 모두 남아야 함', () => {
const settingsInit = { form: { name: 'shipping' } };
const notificationInit = { notificationDefinitionCurrentPage: 1 };
const merged = mergeLocalInitSlot(settingsInit, notificationInit) as Record<string, any>;
expect(merged.form).toEqual({ name: 'shipping' });
expect(merged.notificationDefinitionCurrentPage).toBe(1);
});
it('한쪽에만 있는 _forceLocalInit 타임스탬프가 보존되어야 함', () => {
// settings만 refetchOnMount: true → _forceLocalInit 보유
const settingsInit = { form: { name: 'shipping' }, _forceLocalInit: 1700000000000 };
const notificationInit = { notificationDefinitionCurrentPage: 1 };
const merged = mergeLocalInitSlot(settingsInit, notificationInit) as Record<string, any>;
expect(merged._forceLocalInit).toBe(1700000000000);
});
it('양쪽 모두 _forceLocalInit을 가지면 최신 타임스탬프를 취해야 함', () => {
const older = { a: 1, _forceLocalInit: 1700000000000 };
const newer = { b: 2, _forceLocalInit: 1700000001000 };
const merged = mergeLocalInitSlot(older, newer) as Record<string, any>;
expect(merged._forceLocalInit).toBe(1700000001000);
});
it('이미 소비된 슬롯은 병합하지 않고 교체해야 함 (stale 재적용 방지)', () => {
const settingsInit = { form: { name: 'shipping' } };
// 렌더러가 슬롯을 관측 (DynamicRenderer의 _localInit useEffect)
markLocalInitConsumed(settingsInit);
// 이후 refetchDataSource가 다른 소스의 initLocal을 실어 옴
const refetchInit = { notificationDefinitionCurrentPage: 2 };
const merged = mergeLocalInitSlot(settingsInit, refetchInit) as Record<string, any>;
// 소비가 끝난 form은 재적용 대상이 아님 → 사용자 폼 편집이 되돌아가지 않음
expect(merged.form).toBeUndefined();
expect(merged.notificationDefinitionCurrentPage).toBe(2);
});
it('새 payload가 없으면 기존 슬롯 참조를 그대로 유지해야 함 (소비부 effect 재발화 방지)', () => {
const slot = { form: { name: 'shipping' } };
// updateTemplateData({ someData: 1 }) 처럼 _localInit이 없는 업데이트
expect(mergeLocalInitSlot(slot, undefined)).toBe(slot);
});
it('레이아웃 전환 시 추적 레지스트리가 초기화되어야 함', () => {
const slot = { form: { name: 'shipping' } };
markLocalInitConsumed(slot);
expect(isLocalInitConsumed(slot)).toBe(true);
// TemplateApp.loadRoute의 레이아웃 전환 감지 → _local 리셋과 함께 호출
resetLocalInitTracking();
expect(isLocalInitConsumed(slot)).toBe(false);
expect(getLocalInitTracking().hash).toBe('');
});
});
});
@@ -0,0 +1,149 @@
/**
* `_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;
}
/**
* `_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;
}
@@ -0,0 +1,36 @@
/**
* Playwright 이커머스 인증 fixture.
*
* 코어 `auth.ts` 의 `issueToken` / `authenticatePage` 헬퍼를 재사용해
* 이커머스 도메인 spec 이 쓰는 토큰 fixture 를 구성한다.
* 권한 식별자는 임의 string 이므로 코어 헬퍼를 그대로 쓸 수 있다.
*
* 참조: docs/testing/e2e-testing.md "인증 가드"
*/
import { test as base } from '@playwright/test';
import { authenticatePage, issueToken } from './auth';
/**
* 이커머스 spec 용 토큰 fixture.
*
* - memberToken : 일반 회원 (마이페이지 주문/리뷰 등 본인 리소스 접근)
* - ordersUpdateToken : 주문 상태 변경 권한 (관리자 주문 관리)
*
* 토큰은 spec 별로 lazy 발급된다.
*/
type EcommerceAuthFixtures = {
memberToken: string;
ordersUpdateToken: string;
};
export const test = base.extend<EcommerceAuthFixtures>({
memberToken: async ({}, use) => {
await use(issueToken('sirsoft-ecommerce.orders.read'));
},
ordersUpdateToken: async ({}, use) => {
await use(issueToken('sirsoft-ecommerce.orders.update'));
},
});
export { authenticatePage, issueToken };
export { expect } from '@playwright/test';
@@ -0,0 +1,180 @@
/**
* Smoke: `initLocal` 을 가진 progressive 데이터소스가 둘 이상인 화면에서, SPA 네비게이션으로
* 진입할 때 먼저 도착한 소스의 초기값이 유실되던 회귀 (engine-v1.52.2).
*
* 버그:
* progressive 소스는 응답이 오는 대로 각자 `updateTemplateData({ _localInit: 자기 payload })`
* 를 호출한다. 그런데 `_localInit` 은 단일 슬롯이고 얕은 스프레드(`...data`)로 교체되었다.
* 소비부(DynamicRenderer 의 `_localInit` useEffect)는 React commit 이후에 실행되므로,
* 두 번의 updateTemplateData 가 commit 사이에 연달아 들어오면 슬롯에는 마지막 payload 만 남고
* 먼저 도착한 소스의 payload 는 한 번도 관측되지 않은 채 사라진다.
*
* admin_ecommerce_settings.json 은 `settings`(initLocal: "form", refetchOnMount) 와
* `ecommerceNotificationDefinitions`(initLocal 맵) 을 모두 progressive 로 갖는다.
* 그 결과 배송설정 탭의 배송가능 국가 표가 간헐적으로 비어 "No shipping countries registered."
* 가 표시되었다. 직접 URL 접근/새로고침으로는 재현되지 않는다 (SPA 진입에서만 발생).
*
* 수정:
* 아직 어떤 렌더러도 관측하지 않은(unconsumed) `_localInit` 슬롯만 누적 병합한다.
* 이미 관측된 슬롯은 종전대로 교체한다 — 그래야 사용자가 폼을 편집한 뒤 다른 소스를 refetch 해도
* 소비가 끝난 과거 payload 가 재적용되어 편집 결과를 되돌리지 않는다.
*
* 검증:
* 응답 도착 간격은 원인이 아니다(81ms 실패 / 62ms 통과). 결정 요인은 두 updateTemplateData 사이에
* React commit 이 끼어드는지 여부다. 정상 CPU 에서는 간헐적이라 거짓 통과하므로,
* CDP `Emulation.setCPUThrottlingRate` 로 commit 을 지연시켜 경합 창을 결정적으로 연다.
* (수정 전 번들은 이 조건에서 8/8 실패, 수정 후 8/8 통과 — 브라우저 A/B 실측 확인됨)
*
* @scenario source=localinit-multi-progressive axis=entry:spa-navigation,cpu:throttled
* @effects no-lost-initlocal, table-rendered, no-empty-state
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
/** 이커머스 환경설정 화면 진입에 필요한 권한 토큰 */
function settingsToken(): string {
return issueToken(
'core.settings.read',
'sirsoft-ecommerce.settings.read',
'core.admin.identity.policies.read',
);
}
/** CPU 스로틀링으로 React commit 을 지연시켜 `_localInit` 경합 창을 연다. */
async function throttleCpu(page: import('@playwright/test').Page, rate: number): Promise<void> {
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setCPUThrottlingRate', { rate });
}
/** SPA 네비게이션으로 배송설정 탭에 진입한다 (직접 URL 접근으로는 재현되지 않는다). */
async function navigateToShippingTab(page: import('@playwright/test').Page): Promise<void> {
await page.evaluate(() => {
(window as any).G7Core.dispatch({ handler: 'navigate', params: { path: '/admin/dashboard' } });
});
await page.waitForTimeout(2000);
await page.evaluate(() => {
(window as any).G7Core.dispatch({
handler: 'navigate',
params: { path: '/admin/ecommerce/settings', query: { tab: 'shipping' } },
});
});
}
test('@smoke initLocal progressive 소스가 둘 이상이어도 먼저 도착한 초기값이 유실되지 않는다', async ({ page }) => {
await authenticatePage(page, settingsToken());
await page.goto('/admin/dashboard');
await page.waitForFunction(() => !!(window as any).G7Core?.dispatch);
// commit 을 지연시켜 두 progressive 응답이 같은 commit 사이에 도착하도록 만든다.
await throttleCpu(page, 6);
await navigateToShippingTab(page);
// settings 소스의 initLocal("form") 이 _local 에 반영되어야 한다.
await expect
.poll(
() =>
page.evaluate(
() =>
(window as any).G7Core.state.getLocal()?.form?.shipping?.available_countries?.length ?? null,
),
{ timeout: 30_000, message: 'settings 데이터소스의 initLocal(form) 이 _local 에 반영되어야 함' },
)
.toBeGreaterThan(0);
// 나중에 도착한 notification 소스의 initLocal 도 함께 살아 있어야 한다 (한쪽만 남으면 안 됨).
const notifPage = await page.evaluate(
() => (window as any).G7Core.state.getLocal()?.notificationDefinitionCurrentPage ?? null,
);
expect(notifPage).not.toBeNull();
// 사용자 화면: 국가 표가 렌더되고, 빈 상태 문구는 보이지 않아야 한다.
// (상태 반영과 DOM 커밋 사이에 지연이 있으므로 표 렌더를 먼저 기다린다)
await expect.poll(() => page.locator('tbody tr').count(), { timeout: 15_000 }).toBeGreaterThan(0);
await expect(page.getByText('No shipping countries registered.')).toBeHidden();
});
test('@smoke 폼 편집 후 다른 데이터소스를 refetch 해도 편집값이 되돌아가지 않는다', async ({ page }) => {
await authenticatePage(page, settingsToken());
await page.goto('/admin/dashboard');
await page.waitForFunction(() => !!(window as any).G7Core?.dispatch);
await navigateToShippingTab(page);
await expect
.poll(
() =>
page.evaluate(
() => (window as any).G7Core.state.getLocal()?.form?.shipping?.available_countries?.length ?? 0,
),
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
// 나중에 도착하는 소스(notification)의 `_localInit` 까지 소비가 끝난 뒤에 편집해야 한다.
// 소비 직전에 편집하면 그 편집이 나중 소비에 덮이는데, 그것은 본 규칙이 다루는 상황이 아니다.
// 검증 대상은 "소비가 끝난 payload 가 이후 refetch 로 재적용되지 않는가" 이다.
//
// 마지막 슬롯의 키 구성은 시행마다 다르다 — 두 payload 가 같은 commit 사이에 도착하면 합집합이,
// 각각 별도 commit 에서 소비되면 나중 payload 만 담긴다. 둘 다 정상이므로 키 구성에는 기대지 않고,
// 두 소스의 initLocal 이 모두 `_local` 에 도달했는지로 판정한다.
await expect
.poll(
() =>
page.evaluate(
() => (window as any).G7Core.state.getLocal()?.notificationDefinitionCurrentPage ?? null,
),
{ timeout: 20_000, message: '나중 도착 소스의 initLocal 까지 소비되어야 함' },
)
.not.toBeNull();
// 소비 직후의 후속 커밋이 끝나도록 잠시 안정화한다 (편집이 in-flight 소비에 덮이는 것 방지).
await page.waitForTimeout(1500);
// 사용자가 폼을 편집한다. (편집이 _local 에 반영될 때까지 대기 — 고정 sleep 금지)
await page.evaluate(() => {
const G7Core = (window as any).G7Core;
const form = G7Core.state.getLocal().form;
G7Core.state.setLocal({
form: { ...form, shipping: { ...form.shipping, free_shipping_threshold: 999999 } },
});
});
await expect
.poll(
() =>
page.evaluate(
() => (window as any).G7Core.state.getLocal()?.form?.shipping?.free_shipping_threshold,
),
{ timeout: 10_000, message: '편집값이 _local 에 반영되어야 함' },
)
.toBe(999999);
// 다른 소스를 refetch — 소비가 끝난 settings payload 가 재적용되면 편집이 되돌아간다.
// refetch 응답 도착을 명시적으로 기다린 뒤 판정한다 (고정 sleep 은 응답 전 단언 위험).
const refetchDone = page.waitForResponse(
(res) => res.url().includes('/api/admin/notification-definitions') && res.status() === 200,
{ timeout: 20_000 },
);
await page.evaluate(() => {
(window as any).G7Core.dispatch({
handler: 'refetchDataSource',
params: { dataSourceId: 'ecommerceNotificationDefinitions' },
});
});
await refetchDone;
// refetch 결과가 _local 에 반영될 때까지 기다린 뒤(= 소비 완료), 편집값이 살아 있는지 본다.
await expect
.poll(
() =>
page.evaluate(
() => (window as any).G7Core.state.getLocal()?.notificationDefinitionCurrentPage ?? null,
),
{ timeout: 15_000, message: 'refetch 한 소스의 initLocal 이 _local 에 반영되어야 함' },
)
.not.toBeNull();
const threshold = await page.evaluate(
() => (window as any).G7Core.state.getLocal()?.form?.shipping?.free_shipping_threshold,
);
expect(threshold).toBe(999999);
});