fix(core): 자동바인딩 키입력이 편집기 본문을 서버 원본으로 되돌리던 문제

자동바인딩은 키입력마다 __g7PendingLocalState 에 저장소 A 기반 전체 스냅샷을
대입해 왔다. 그 base(parentFormContext.state)는 extendedDataContext useMemo 의
결과라, selfManaged 플러그인이 저장소 B 에만 쓴 뒤 memo 가 재계산되지 않은
구간에서는 편집 이전 스냅샷으로 고정된다. 이어지는 setLocal 이
currentSnapshot = pendingState || baseLocal 로 그것을 채택하면 저장소 B 가
통째 교체되어 편집분이 사라진다.

pending 은 getLocal 이 읽는 "화면과 같은 전체 스냅샷" 이므로, 렌더러가
_local 을 만드는 순서(dataContext._local → dynamicState → __g7ForcedLocalFields)를
그대로 따르게 합성한다. 방금 입력한 경로는 마지막에 다시 얹어 오버레이의 직전
값이 입력을 되돌리지 못하게 했다.

저장소 A 경로와 setLocal 의 base 우선순위는 건드리지 않았다. 전자는 2026-04-22 에
로그인 폼 email 손실로 철회된 자리이고, 후자는 _localInit 이 초기 데이터를
pending 에만 싣는 구간을 깨뜨린다.

engine-v1.63.3이 저장 클릭 경로를 고쳤다면 이번 수정은 그 앞의 키입력
경로를 고친다. 성립 조건에는 memo deps 와 무관한 리렌더가 선행해야 하며,
리사이즈 없는 대조군은 수정 전에도 정상이다(브라우저 실측).

트러블슈팅 246 사례 전수 판정: 영향 있음 0 · 개선 3 · 불변 10.
This commit is contained in:
HeuJung
2026-09-01 19:57:24 +09:00
parent cd189190c2
commit de584ea93f
9 changed files with 498 additions and 21 deletions
+3
View File
@@ -323,9 +323,12 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
| sequence 반환값을 stale base 로 구성 | 반환값도 live B 기반 + `addMissingLeafKeys` 로 A 전용 키 보충 | | sequence 반환값을 stale base 로 구성 | 반환값도 live B 기반 + `addMissingLeafKeys` 로 A 전용 키 보충 |
| 두 쓰기 경로(B 쓰기 / 반환값)에 서로 다른 병합 규칙 | 같은 규칙 — 갈라지면 나중에 소비자가 생길 때 어느 경로를 탔느냐로 결과가 달라진다 | | 두 쓰기 경로(B 쓰기 / 반환값)에 서로 다른 병합 규칙 | 같은 규칙 — 갈라지면 나중에 소비자가 생길 때 어느 경로를 탔느냐로 결과가 달라진다 |
| `__g7ForcedLocalFields` 오버레이가 있으니 `context.state` 도 최신이라고 가정 | 그 오버레이는 `extendedDataContext` **useMemo 안에서 읽는 window 전역**이라 deps 가 아니다 — memo 가 재계산되지 않으면 실리지 않는다 | | `__g7ForcedLocalFields` 오버레이가 있으니 `context.state` 도 최신이라고 가정 | 그 오버레이는 `extendedDataContext` **useMemo 안에서 읽는 window 전역**이라 deps 가 아니다 — memo 가 재계산되지 않으면 실리지 않는다 |
| 자동바인딩이 `__g7PendingLocalState` 에 저장소 A 스냅샷을 그대로 대입 | 렌더러와 같은 순서로 `__g7ForcedLocalFields` 를 얹고 방금 입력한 경로를 다시 적용 — pending 은 `getLocal()` 이 읽는 "화면과 같은 전체 스냅샷" 이다 |
A 가 값을 못 받는 대표 경로는 `setLocal({ render: false, selfManaged: true })`(CKEditor 등 자체 DOM 관리 플러그인)다. `render:false` 는 `updateTemplateData` 앞에서 조기 return 하고 액션 밖이라 `__g7ActionContext` 도 없으므로 **React 렌더가 0회** — memo 가 재계산되지 않아 `context.state` 가 입력 이전 스냅샷으로 고정된다. 여기에 폭 변경 리렌더가 `__g7PendingLocalState` 를 null 로 지우면(의존성 배열 없는 `useLayoutEffect`) base 가 stale A 로 떨어진다. A 가 값을 못 받는 대표 경로는 `setLocal({ render: false, selfManaged: true })`(CKEditor 등 자체 DOM 관리 플러그인)다. `render:false` 는 `updateTemplateData` 앞에서 조기 return 하고 액션 밖이라 `__g7ActionContext` 도 없으므로 **React 렌더가 0회** — memo 가 재계산되지 않아 `context.state` 가 입력 이전 스냅샷으로 고정된다. 여기에 폭 변경 리렌더가 `__g7PendingLocalState` 를 null 로 지우면(의존성 배열 없는 `useLayoutEffect`) base 가 stale A 로 떨어진다.
pending 은 저장소 B 의 base 가 된다 — `setLocal` 이 `currentSnapshot = pendingState || baseLocal` 로 pending 을 우선 채택하기 때문이다. 그래서 A 스냅샷을 그대로 실으면 위와 같은 통째 교체가 **저장 클릭 전, 키입력 시점에** 일어난다. 방아쇠는 memo deps 와 무관한 리렌더(폭 변경 등)가 선행하는 것이고, 그것이 없으면 성립하지 않는다.
이 결함군은 예외도 콘솔 에러도 남기지 않는다 — 화면에는 본문이 그대로 보이는데 요청 body 만 비어 나가고(작성 화면 422), 수정 화면에서는 성공 토스트와 함께 **직전 본문이 저장되어 편집분이 사라진다**. 정적 검사가 `_local` 동기화 호출의 base 를 검사한다. 이 결함군은 예외도 콘솔 에러도 남기지 않는다 — 화면에는 본문이 그대로 보이는데 요청 body 만 비어 나가고(작성 화면 422), 수정 화면에서는 성공 토스트와 함께 **직전 본문이 저장되어 편집분이 사라진다**. 정적 검사가 `_local` 동기화 호출의 base 를 검사한다.
### 핸들러 정의 ### 핸들러 정의
+1
View File
@@ -39,6 +39,7 @@
- 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.) - 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.) - 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.)
- 창 크기가 바뀐 뒤 본문을 고치고 제목 등 다른 입력칸을 건드리면, 저장 시 본문이 고치기 전 내용으로 되돌아가던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 편집기에는 고친 내용이 그대로 보였기 때문에 저장 후 다시 열어보기 전까지는 알 수 없었습니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다.
- 서버측 라이브러리 guzzle·commonmark 의 알려진 취약점 12건을 해소했습니다. 결제·본인인증·알림 발송처럼 외부와 통신하는 경로가 이 라이브러리를 사용합니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.) - 서버측 라이브러리 guzzle·commonmark 의 알려진 취약점 12건을 해소했습니다. 결제·본인인증·알림 발송처럼 외부와 통신하는 경로가 이 라이브러리를 사용합니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 초기 화면 파일을 명령줄과 웹이 번갈아 만들 때, 나중에 생기는 하위 폴더가 한쪽 계정 전용으로 남아 다른 쪽이 쓰지 못하던 문제를 수정했습니다. 게시 폴더가 그룹 권한을 하위 폴더에 물려주도록 정리합니다(Linux·macOS). - 초기 화면 파일을 명령줄과 웹이 번갈아 만들 때, 나중에 생기는 하위 폴더가 한쪽 계정 전용으로 남아 다른 쪽이 쓰지 못하던 문제를 수정했습니다. 게시 폴더가 그룹 권한을 하위 폴더에 물려주도록 정리합니다(Linux·macOS).
- 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다). - 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다).
+4
View File
@@ -161,6 +161,8 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
- **A→B 방향**: 자동바인딩 `performStateUpdate`가 A에 쓸 때 B에도 `setLocal({render:false})`로 동기 기록 - **A→B 방향**: 자동바인딩 `performStateUpdate`가 A에 쓸 때 B에도 `setLocal({render:false})`로 동기 기록
- **B→A 방향**: 자동바인딩 활성 경로를 `__g7AutoBindingPaths: Map<string, number>`에 추적. 플러그인이 `setLocal({render:false})`로 그 경로를 건드리면 엔진이 **자동으로 `render:true`로 승격** - **B→A 방향**: 자동바인딩 활성 경로를 `__g7AutoBindingPaths: Map<string, number>`에 추적. 플러그인이 `setLocal({render:false})`로 그 경로를 건드리면 엔진이 **자동으로 `render:true`로 승격**
- **예외**: `selfManaged: true` 명시한 호출은 자동 승격 제외 (CKEditor5 등 자체 DOM 관리 플러그인 전용) - **예외**: `selfManaged: true` 명시한 호출은 자동 승격 제외 (CKEditor5 등 자체 DOM 관리 플러그인 전용)
- **pending 스냅샷**: 자동바인딩은 `__g7PendingLocalState` 에 "지금 화면과 같은 전체 스냅샷" 을 싣는다. 이 값이 뒤이은 `setLocal` 의 base 가 되므로, 저장소 A 스냅샷을 그대로 실으면 B 에만 있던 값(selfManaged 플러그인이 쓴 편집기 본문 등)이 사라진다. 그래서 렌더러가 화면을 만드는 순서(`dataContext._local → dynamicState → __g7ForcedLocalFields`)를 그대로 따라 합성한다 (engine-v1.63.4)
### 엔진 수정 시 금지 사항 (CRITICAL) ### 엔진 수정 시 금지 사항 (CRITICAL)
@@ -169,6 +171,7 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
❌ `parentFormContext.setState`를 직접 호출하는 우회 경로 추가 (자동바인딩 내부 API) ❌ `parentFormContext.setState`를 직접 호출하는 우회 경로 추가 (자동바인딩 내부 API)
❌ `__g7AutoBindingPaths` 레지스트리를 건드리지 않고 자동바인딩 변형 구현 ❌ `__g7AutoBindingPaths` 레지스트리를 건드리지 않고 자동바인딩 변형 구현
❌ setLocal의 `render:false` 자동 승격 분기를 임의로 제거하거나 조건 완화 ❌ setLocal의 `render:false` 자동 승격 분기를 임의로 제거하거나 조건 완화
❌ 자동바인딩의 pending 스냅샷을 저장소 A 값만으로 구성 (B 전용 값이 조용히 사라진다)
❌ 구독 기반 선택적 리렌더 재시도 (과거에 도입 후 롤백된 실패 경로 — 반드시 검토 후 논의) ❌ 구독 기반 선택적 리렌더 재시도 (과거에 도입 후 롤백된 실패 경로 — 반드시 검토 후 논의)
``` ```
@@ -177,6 +180,7 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
```text ```text
✅ _local 쓰기 경로 추가 시 A+B 양쪽 동기화 확인 ✅ _local 쓰기 경로 추가 시 A+B 양쪽 동기화 확인
✅ 새 `setLocal({render:false})` 사용처가 자동바인딩 경로와 겹치는지 확인 (겹치면 selfManaged 필요) ✅ 새 `setLocal({render:false})` 사용처가 자동바인딩 경로와 겹치는지 확인 (겹치면 selfManaged 필요)
✅ pending 에 쓰는 값이 렌더러가 만드는 `_local` 과 같은 합성 순서인지 확인
✅ DynamicRenderer의 레지스트리 useEffect 조건 변경 시 iteration/Strict Mode 이중 마운트 영향 검토 ✅ DynamicRenderer의 레지스트리 useEffect 조건 변경 시 iteration/Strict Mode 이중 마운트 영향 검토
✅ SPA 네비게이션 시 레지스트리 재초기화 (new Map()) 유지 ✅ SPA 네비게이션 시 레지스트리 재초기화 (new Map()) 유지
✅ 수정 후 이중 저장소 동기화 관련 회귀 테스트 전수 통과 확인 ✅ 수정 후 이중 저장소 동기화 관련 회귀 테스트 전수 통과 확인
@@ -348,4 +348,90 @@ test.describe('폭 변경 후 저장 — 편집기 본문 보존', () => {
}); });
expect(canonical, 'setState 가 저장소 B 의 본문을 지우지 않아야 한다').toContain(marker); expect(canonical, 'setState 가 저장소 B 의 본문을 지우지 않아야 한다').toContain(marker);
}); });
test('폭 변경 뒤 자동바인딩 입력이 편집분을 되돌리지 않는다 (사례 41)', async ({ page, editorToken }) => {
// @scenario save_flow=edit, resize_kind=same_breakpoint
// @effects autobinding_keystroke_preserves_editor_content, pending_snapshot_carries_forced_overlay
//
// engine-v1.63.4. 같은 뿌리(저장소 B 통째 교체)의 **다른 방아쇠**다 — 손실이 저장 클릭이
// 아니라 그 앞의 키입력에서 일어나므로 engine-v1.63.3 의 수정은 이 경로를 지나가지 않는다.
//
// 순서가 중요하다: 본문 편집 → 폭 변경 → **제목 입력** → 저장.
// 폭 변경으로 pending 이 비워진 뒤의 자동바인딩 키입력이 stale 한 저장소 A 스냅샷을
// pending 에 실으면, 이어지는 setLocal 이 그것을 base 로 채택해 B 를 통째 교체한다.
//
// 판정은 화면이 아니라 요청 body 로 한다 — 수정 화면은 성공 토스트가 뜨고 서버 원본이
// 저장되므로 화면 피드백이 근거가 되지 못한다.
const stamp = Date.now();
const original = `AUTOBIND-ORIG-${stamp}`;
const added = `AUTOBIND-EDIT-${stamp}`;
await page.setViewportSize(SAME_BREAKPOINT.from);
await authenticatePage(page, editorToken);
// 원본 글을 만든다 (리사이즈 없이 — 대조군과 같은 경로)
await page.goto(`/admin/board/${BOARD}/create`);
const createEditor = page.locator('.ck-editor__editable').first();
await expect(createEditor).toBeVisible({ timeout: 30_000 });
await page.locator('input[name="title"]').pressSequentially(`제목 ${stamp}`);
await createEditor.click();
await createEditor.pressSequentially(original);
await waitForDebounceFlush(page, original);
const [createRequest, createResponse] = await Promise.all([
page.waitForRequest((r) => r.url().includes('/posts') && r.method() === 'POST'),
page.waitForResponse((r) => r.url().includes('/posts') && r.request().method() === 'POST'),
page.locator('#footer_save_button').click(),
]);
expect(String(createRequest.postDataJSON().content ?? '')).toContain(original);
const postId = (await createResponse.json())?.data?.id ?? null;
expect(postId, '원본 글 id 를 받아야 한다').not.toBeNull();
try {
// 수정 화면 — 본문을 고치고, 폭을 바꾸고, 그 뒤에 제목을 건드린다
await page.goto(`/admin/board/${BOARD}/${postId}/edit`);
const editor = page.locator('.ck-editor__editable').first();
await expect(editor).toBeVisible({ timeout: 30_000 });
await expect(editor).toContainText(original, { timeout: 30_000 });
await editor.click();
await editor.pressSequentially(added);
await waitForDebounceFlush(page, added); // ① 디바운스 발화 확정
await page.setViewportSize(SAME_BREAKPOINT.to); // ② 폭 변경
await waitForPendingCleared(page); // ③ pending 클리어 확정
// ④ 자동바인딩 키입력 — 여기서 편집분이 사라졌다
await page.locator('input[name="title"]').pressSequentially('!');
await expect
.poll(
async () =>
await page.evaluate(() => {
const c = (window as any).__templateApp?.getGlobalState?.()?._local?.form?.content;
return typeof c === 'string' ? c : JSON.stringify(c ?? '');
}),
{ timeout: 20_000 },
)
.toContain(added);
const [request] = await Promise.all([
page.waitForRequest((r) => r.url().includes('/posts') && r.method() === 'PUT'),
page.locator('#footer_save_button').click(),
]);
const sent = String(request.postDataJSON().content ?? '');
expect(sent, '자동바인딩 키입력 뒤에도 편집분이 전송돼야 한다').toContain(added);
expect(sent, '원본도 함께 남아야 한다').toContain(original);
} finally {
if (postId !== null) {
const del = await page.request.delete(
`/api/modules/sirsoft-board/admin/board/${BOARD}/posts/${postId}`,
{ headers: { Authorization: `Bearer ${editorToken}`, Accept: 'application/json' } },
);
expect(del.ok(), `정리 삭제가 성공해야 한다 (status ${del.status()})`).toBeTruthy();
}
}
});
}); });
File diff suppressed because one or more lines are too long
@@ -5,6 +5,18 @@
> >
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) > 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.63.4] - 2026-09-01
### Fixed
#### 자동바인딩 키입력이 편집기 본문을 서버 원본으로 되돌리던 문제
- 자동바인딩(`performStateUpdate`)이 `__g7PendingLocalState` 에 **저장소 A 기반 전체 스냅샷**을 그대로 대입했다. 그 base(`parentFormContext.state`)는 `extendedDataContext` useMemo 의 결과라, `setLocal({ render:false, selfManaged:true })`(CKEditor5 등)이 저장소 B 에만 쓴 뒤 memo 가 재계산되지 않은 구간에서는 **편집 이전 스냅샷으로 고정**된다. 이어지는 `setLocal` 이 `currentSnapshot = pendingState || baseLocal` 로 그것을 채택해 저장소 B 를 통째 교체하면서 편집분이 사라졌다.
- 이제 pending 은 렌더러가 화면을 만드는 순서(`dataContext._local → dynamicState → __g7ForcedLocalFields`)와 같게 합성한다(`composeAutoBindingPendingSnapshot`). pending 은 `getLocal()` 이 읽는 "화면과 같은 전체 스냅샷" 이므로 이 정합이 맞다. 방금 입력한 경로는 마지막에 다시 얹어, 오버레이의 직전 값이 입력을 되돌리지 못하게 한다.
- 성립 조건에는 **memo deps 와 무관한 리렌더**가 선행해야 한다(브라우저 실측: 폭 변경). 그것이 없으면 손실이 없다 — 리사이즈 없는 대조군은 정상이다. engine-v1.63.3(#130)이 저장 클릭 경로를 고쳤다면 이번 수정은 **그 앞의 키입력 경로**를 고친다.
- 작성 화면은 `내용은 필수입니다` 422 로, **수정 화면은 성공 토스트와 함께 서버 원본이 저장되어** 편집분이 조용히 사라졌다. 화면의 편집기에는 고친 내용이 그대로 보이고 콘솔 에러도 없었다.
- 저장소 A 경로(`parentFormContext.setState`)와 `setLocal` 의 base 우선순위는 **건드리지 않는다**. 전자는 2026-04-22 에 로그인 폼 email 손실로 철회된 수정의 자리이고, 후자는 `_localInit`(engine-v1.49.2)이 초기 데이터를 pending 에만 실어 두는 구간을 깨뜨린다.
## [engine-v1.63.3] - 2026-09-01 ## [engine-v1.63.3] - 2026-09-01
### Fixed ### Fixed
@@ -219,6 +219,50 @@ export const deepMergeState = (
return result; return result;
}; };
/**
* 자동바인딩이 `__g7PendingLocalState` 에 실을 스냅샷을 합성합니다.
*
* 자동바인딩의 base 는 `parentFormContext.state`(= `extendedDataContext._local`)다.
* 그 값은 `useMemo` 결과라, memo 가 재계산되지 않은 구간에서는 **입력 이전 스냅샷으로 고정**된다.
* `setLocal({ render:false, selfManaged:true })`(CKEditor5 등 자체 DOM 관리 플러그인)이
* 저장소 B 에만 쓰면 React 렌더가 0회라 memo 가 갱신되지 않고, 그 뒤 memo deps 와 무관한
* 리렌더(폭 변경 등)가 `__g7PendingLocalState` 를 null 로 지우면 base 가 stale 인 채 남는다.
*
* 그 stale 스냅샷을 그대로 pending 에 실으면, 이어지는 `setLocal` 이
* `currentSnapshot = pendingState || baseLocal`(G7CoreGlobals) 로 그것을 채택해
* 저장소 B 를 통째 교체한다 — 편집기 본문이 예외도 콘솔 에러도 없이 사라진다.
*
* 그래서 pending 은 **렌더러가 화면에 쓰는 것과 같은 순서**로 합성한다.
* `extendedDataContext._local` 이 `dataContext._local → dynamicState → __g7ForcedLocalFields`
* 순으로 합성하는 것과 동형이며, pending 은 `getLocal()` 이 읽는 "화면과 같은 전체 스냅샷"
* 이어야 하므로 이 정합이 맞다.
*
* `__g7ForcedLocalFields` 는 `setLocal` 이 쓴 필드만 담고(engine-v1.17.4 · 사례 11),
* 자동바인딩의 매 키입력이 자기 경로로 그것을 갱신하므로 stale 이 되지 않는다.
* 방금 입력한 경로만 마지막에 다시 얹어, forced 의 직전 값이 입력을 되돌리지 못하게 한다.
*
* 저장소 A 경로(`parentFormContext.setState`)에는 적용하지 않는다 — 그쪽 base 를 바꾸는 것이
* 2026-04-22 에 로그인 폼 email 손실로 철회된 수정이다(사례 36 이력).
*
* @param update 저장소 A 기반 전체 스냅샷 (`setNestedValue(parentFormContext.state, path, value)`)
* @param forced `__g7ForcedLocalFields` — setLocal 이 소유한 필드만 담긴 오버레이
* @param fullPath 방금 입력된 필드 경로 (`${dataKey}.${name}`)
* @param newValue 방금 입력된 값
* @returns pending 에 실을 스냅샷 (인자를 변이하지 않음)
*
* @since engine-v1.63.4
*/
export const composeAutoBindingPendingSnapshot = (
update: Record<string, any>,
forced: unknown,
fullPath: string,
newValue: any
): Record<string, any> => {
if (!forced || typeof forced !== 'object' || Array.isArray(forced)) return update;
const merged = deepMergeState(update, forced as Record<string, any>);
return setNestedValue(merged, fullPath, newValue);
};
/** /**
* setLocal()이 업데이트한 키를 localDynamicState에서 재귀적으로 제거 * setLocal()이 업데이트한 키를 localDynamicState에서 재귀적으로 제거
* *
@@ -3608,7 +3652,18 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
// 저장소 A: React setState가 비동기이므로, 액션 핸들러에서 즉시 최신 값을 읽을 수 있도록 // 저장소 A: React setState가 비동기이므로, 액션 핸들러에서 즉시 최신 값을 읽을 수 있도록
// 전역 캐시(__g7PendingLocalState)에 업데이트된 상태를 저장. G7Core.state.getLocal()이 이 캐시를 우선 사용. // 전역 캐시(__g7PendingLocalState)에 업데이트된 상태를 저장. G7Core.state.getLocal()이 이 캐시를 우선 사용.
(window as any).__g7PendingLocalState = update; //
// engine-v1.63.4 (사례 41): `update` 를 그대로 싣지 않는다. base 인 parentFormContext.state 는
// 재계산되지 않은 useMemo 결과일 수 있어, selfManaged 플러그인이 저장소 B 에만 쓴 편집분을
// 담고 있지 않다. 그 stale 스냅샷이 pending 에 실리면 아래 setLocal 이 그것을 base 로 채택해
// (`currentSnapshot = pendingState || baseLocal`) 저장소 B 를 통째 교체한다.
// 렌더러와 같은 순서로 __g7ForcedLocalFields 를 얹어 화면과 같은 스냅샷을 싣는다.
(window as any).__g7PendingLocalState = composeAutoBindingPendingSnapshot(
update,
(window as any).__g7ForcedLocalFields,
fullPath,
newValue
);
// 저장소 A: React localDynamicState — 해당 Input 트리 리렌더 (기존 경로, 성능 보존) // 저장소 A: React localDynamicState — 해당 Input 트리 리렌더 (기존 경로, 성능 보존)
parentFormContext.setState!(update); parentFormContext.setState!(update);
@@ -0,0 +1,296 @@
/**
* DynamicRenderer.autoBindingPendingBase.test.tsx
*
* 사례 41(engine-v1.63.4) — 자동바인딩 키입력이 저장소 B 의 편집기 본문을 되돌리는 결함.
*
* 브라우저 실측(2026-09-01, 관리자 게시글 **수정** 화면)으로 확정된 조건:
*
* ① CKEditor 가 `setLocal({ render:false, selfManaged:true })` 로 저장소 B 에만 편집분을 기록
* → React 렌더 0회 → `extendedDataContext` useMemo 가 재계산되지 않아
* `parentFormContext.state`(저장소 A)는 **편집 이전 스냅샷**으로 고정된다
* ② memo deps 와 무관한 리렌더(폭 변경)가 `__g7PendingLocalState` 를 null 로 지운다
* — 이때 `__g7ForcedLocalFields` 는 **살아남는다**(사례 40 위험 핀이 잠근 사실)
* ③ 그 상태에서 제목 등 자동바인딩 입력에 한 글자 → `performStateUpdate` 가
* `__g7PendingLocalState = setNestedValue(<stale A>, path, v)` 를 대입
* ④ 이어지는 `setLocal(patch, {render:false})` 가
* `currentSnapshot = pendingState || baseLocal` 로 그 stale 스냅샷을 채택
* → 저장소 B 가 통째 교체되어 **편집분이 사라진다**
*
* 실측값(수정 화면, 폭 1051→1032):
* setLocal 진입 시 pending=`<p>F6MARKER-BODY-FIRST</p>`(서버 원본)
* forced =`<p>ZZEDITF6MARKER-BODY-FIRST</p>`(편집분 보유)
* liveB =`<p>ZZEDITF6MARKER-BODY-FIRST</p>`
* 호출 직후 liveB =`<p>F6MARKER-BODY-FIRST</p>` ← ZZEDIT 소실
* 저장 결과 DB content 가 서버 원본 그대로, title 만 반영 (조용한 데이터 손실)
*
* 화면에는 편집분이 그대로 보이고 콘솔 에러도 없다. 저장은 **성공**하므로
* 사용자에게는 아무 신호가 없다.
*
* 이 파일은 ③④를 **실물 렌더 + 실물 `G7Core.state.setLocal`** 로 잠근다.
* 저장소 A 가 stale 인 상태는 `parentFormContextProp.state` 로 그대로 재현한다
* (production 에서 그 값이 곧 재계산되지 않은 useMemo 결과다).
*
* @see 트러블슈팅 사례 41
* @see DynamicRenderer.resizePendingClear.test.tsx (②를 잠그는 사례 40 위험 핀)
*/
import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, cleanup, fireEvent, screen } from '@testing-library/react';
import DynamicRenderer, { ComponentDefinition } from '../DynamicRenderer';
import { ComponentRegistry } from '../ComponentRegistry';
import { DataBindingEngine } from '../DataBindingEngine';
import { TranslationEngine, TranslationContext } from '../TranslationEngine';
import { ActionDispatcher } from '../ActionDispatcher';
import { initializeG7CoreGlobals } from '../G7CoreGlobals';
vi.mock('../ResponsiveManager', async (importOriginal) => {
const actual = await importOriginal<typeof import('../ResponsiveManager')>();
return {
...actual,
responsiveManager: {
getWidth: vi.fn(() => 1440),
subscribe: vi.fn(() => () => {}),
getMatchingKey: vi.fn(() => null),
parseRange: vi.fn(() => null),
},
};
});
/** 자동바인딩 대상 — name/value/onChange 를 그대로 DOM 에 넘긴다 */
const TestInput: React.FC<any> = ({ name, value, onChange }) => (
<input data-testid={`input-${name}`} name={name} value={value ?? ''} onChange={onChange} />
);
const BODY = '<p>ZZEDIT서버원본</p>';
const SERVER_ORIGINAL = '<p>서버원본</p>';
describe('[사례 41] 자동바인딩 키입력이 저장소 B 의 편집분을 되돌리지 않는다 (engine-v1.63.4)', () => {
let registry: ComponentRegistry;
let bindingEngine: DataBindingEngine;
let translationEngine: TranslationEngine;
let actionDispatcher: ActionDispatcher;
let translationContext: TranslationContext;
let globalState: Record<string, any>;
let originalG7Core: any;
/** 저장소 B (TemplateApp 계약대로 최상위 키 얕은 병합) */
const makeTemplateApp = () => ({
getGlobalState: () => globalState,
setGlobalState: (updates: any) => {
globalState = { ...globalState, ...updates };
},
});
const liveB = () => globalState._local;
/**
* `setLocal` **진입 시점**의 `__g7PendingLocalState` 를 포착합니다.
*
* 호출이 끝나면 `debouncedStateUpdate` 의 `onComplete` 가 pending 을 null 로 지우므로
* (DynamicRenderer.tsx:3648·3657) 사후 관측으로는 판정할 수 없다. 브라우저 실측에서도
* 같은 이유로 `setLocal` 을 감싸 진입 시점 값을 잡았다.
*
* @return 호출별 진입 시점 스냅샷 배열
*/
const captureSetLocalEntry = (): Array<Record<string, any> | null> => {
const seen: Array<Record<string, any> | null> = [];
const G7Core = (window as any).G7Core;
const orig = G7Core.state.setLocal;
G7Core.state.setLocal = function (...args: any[]) {
const p = (window as any).__g7PendingLocalState;
seen.push(p == null ? null : JSON.parse(JSON.stringify(p)));
return orig.apply(this, args);
};
return seen;
};
const componentDef: ComponentDefinition = {
id: 'title-input',
type: 'basic',
name: 'Input',
props: { name: 'title' },
};
/**
* 저장소 A 가 stale 인 상태로 자동바인딩 Input 을 렌더합니다.
*
* @param staleA 재계산되지 않은 `extendedDataContext._local` 을 모사한 값
* @return `parentFormContext.setState` 가 받은 값을 담는 배열
*/
const renderInput = (staleA: Record<string, any>) => {
const setStateCalls: any[] = [];
render(
<DynamicRenderer
componentDef={componentDef}
dataContext={{ _local: staleA }}
translationContext={translationContext}
registry={registry}
bindingEngine={bindingEngine}
translationEngine={translationEngine}
actionDispatcher={actionDispatcher}
parentFormContextProp={{
dataKey: 'form',
state: staleA,
setState: (u: any) => setStateCalls.push(u),
trackChanges: true,
} as any}
/>
);
return setStateCalls;
};
beforeEach(() => {
registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
};
bindingEngine = new DataBindingEngine();
translationEngine = new TranslationEngine();
actionDispatcher = new ActionDispatcher({ navigate: vi.fn() });
translationContext = { templateId: 'test-template', locale: 'ko' };
originalG7Core = (window as any).G7Core;
delete (window as any).G7Core;
globalState = { _local: {} };
(window as any).__templateApp = makeTemplateApp();
(window as any).__g7PendingLocalState = null;
(window as any).__g7ForcedLocalFields = undefined;
(window as any).__g7SetLocalOverrideKeys = undefined;
(window as any).__g7LayoutContextStack = [];
delete (window as any).__g7ActionContext;
initializeG7CoreGlobals({
getState: vi.fn(() => ({
translationEngine,
translationContext,
bindingEngine,
actionDispatcher,
templateMetadata: { locales: ['ko'] },
})) as any,
transitionManager: { getIsPending: vi.fn(() => false), subscribe: vi.fn(() => () => {}) },
responsiveManager: {},
webSocketManager: {
subscribe: vi.fn(() => 's'),
unsubscribe: vi.fn(),
leaveChannel: vi.fn(),
disconnect: vi.fn(),
isInitialized: vi.fn(() => true),
getSubscriptionCount: vi.fn(() => 0),
},
} as any);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
delete (window as any).__templateApp;
delete (window as any).__g7PendingLocalState;
delete (window as any).__g7ForcedLocalFields;
delete (window as any).__g7SetLocalOverrideKeys;
(window as any).__g7LayoutContextStack = [];
if (originalG7Core === undefined) delete (window as any).G7Core;
else (window as any).G7Core = originalG7Core;
});
it('결함 재현 핀: 편집분이 저장소 B 에 남는다', () => {
// @scenario save_flow=edit, resize_kind=same_breakpoint
// @effects autobinding_keystroke_preserves_editor_content
// 저장소 B 는 편집분을 들고 있다 (CKEditor 가 render:false 로 기록)
globalState._local = { form: { title: '제목', content: BODY, content_mode: 'html' } };
(window as any).__g7ForcedLocalFields = { form: { content: BODY, content_mode: 'html' } };
(window as any).__g7SetLocalOverrideKeys = { form: { content: BODY, content_mode: 'html' } };
// 폭 변경 리렌더가 pending 을 지운 뒤다
(window as any).__g7PendingLocalState = null;
// 저장소 A 는 편집 이전 스냅샷으로 고정돼 있다 (memo 미재계산)
renderInput({ form: { title: '제목', content: SERVER_ORIGINAL, content_mode: 'html' } });
fireEvent.change(screen.getByTestId('input-title'), { target: { value: '제목X' } });
expect(liveB().form.content, '자동바인딩 키입력이 편집분을 되돌리면 안 된다').toBe(BODY);
expect(liveB().form.title, '입력한 제목은 반영돼야 한다').toBe('제목X');
});
it('pending 스냅샷 자체가 편집분을 담는다 (후속 setLocal 의 base 가 된다)', () => {
// @scenario save_flow=edit, resize_kind=crossed_breakpoint
// @effects pending_snapshot_carries_forced_overlay
globalState._local = { form: { title: '제목', content: BODY } };
(window as any).__g7ForcedLocalFields = { form: { content: BODY } };
(window as any).__g7SetLocalOverrideKeys = { form: { content: BODY } };
(window as any).__g7PendingLocalState = null;
renderInput({ form: { title: '제목', content: SERVER_ORIGINAL } });
const entries = captureSetLocalEntry();
fireEvent.change(screen.getByTestId('input-title'), { target: { value: 'Q' } });
expect(entries.length, '자동바인딩이 setLocal 로 B 에 써야 한다').toBe(1);
expect(entries[0]?.form?.content, 'pending 이 stale A 를 그대로 실으면 안 된다').toBe(BODY);
expect(entries[0]?.form?.title, '방금 입력한 값도 실려야 한다').toBe('Q');
});
it('방금 입력한 경로는 forced 의 직전 값에 되돌려지지 않는다', () => {
// @scenario save_flow=create, resize_kind=same_breakpoint
// @effects typed_path_wins_over_forced_previous_value
// setLocal 이 과거에 title 을 쓴 적이 있어 forced 에 옛 값이 남은 상태
globalState._local = { form: { title: '옛제목', content: BODY } };
(window as any).__g7ForcedLocalFields = { form: { title: '옛제목', content: BODY } };
(window as any).__g7SetLocalOverrideKeys = { form: { title: '옛제목', content: BODY } };
(window as any).__g7PendingLocalState = null;
renderInput({ form: { title: '옛제목', content: SERVER_ORIGINAL } });
fireEvent.change(screen.getByTestId('input-title'), { target: { value: '새제목' } });
expect(liveB().form.title, '방금 입력한 값이 이겨야 한다').toBe('새제목');
expect(liveB().form.content, '편집분은 그대로 보존').toBe(BODY);
});
it('저장소 A 경로는 종전대로 A 기반이다 (2026-04-22 철회 이력 보호)', () => {
globalState._local = { form: { title: '제목', content: BODY } };
(window as any).__g7ForcedLocalFields = { form: { content: BODY } };
(window as any).__g7SetLocalOverrideKeys = { form: { content: BODY } };
(window as any).__g7PendingLocalState = null;
const staleA = { form: { title: '제목', content: SERVER_ORIGINAL }, expandedRows: [301] };
const setStateCalls = renderInput(staleA);
fireEvent.change(screen.getByTestId('input-title'), { target: { value: 'Z' } });
// parentFormContext.setState 는 A 기반 update 를 받는다 —
// 여기 base 를 B 로 바꾸는 것이 2026-04-22 에 철회된 수정(로그인 폼 email 손실)이다.
expect(setStateCalls.length).toBe(1);
expect(setStateCalls[0].form.title).toBe('Z');
expect(setStateCalls[0].form.content, 'A 경로는 A 기반 그대로').toBe(SERVER_ORIGINAL);
expect(setStateCalls[0].expandedRows, 'React 전용 키가 유지돼야 한다').toEqual([301]);
});
it('forced 가 없으면 종전 동작 (A 기반 스냅샷)', () => {
globalState._local = { form: { title: '제목', content: SERVER_ORIGINAL } };
(window as any).__g7ForcedLocalFields = undefined;
(window as any).__g7PendingLocalState = null;
renderInput({ form: { title: '제목', content: SERVER_ORIGINAL } });
const entries = captureSetLocalEntry();
fireEvent.change(screen.getByTestId('input-title'), { target: { value: 'W' } });
expect(entries[0]?.form?.title).toBe('W');
expect(entries[0]?.form?.content).toBe(SERVER_ORIGINAL);
});
it('다국어 중첩 경로에서도 편집분이 보존된다', () => {
// @scenario save_flow=create, resize_kind=crossed_breakpoint
// @effects autobinding_keystroke_preserves_editor_content
const koBody = '<p>다국어편집분</p>';
globalState._local = { form: { title: '제목', content: { ko: koBody } } };
(window as any).__g7ForcedLocalFields = { form: { content: { ko: koBody } } };
(window as any).__g7SetLocalOverrideKeys = { form: { content: { ko: koBody } } };
(window as any).__g7PendingLocalState = null;
renderInput({ form: { title: '제목', content: { ko: '<p>원본</p>' } } });
fireEvent.change(screen.getByTestId('input-title'), { target: { value: 'M' } });
expect(liveB().form.content.ko, '중첩 경로 편집분 보존').toBe(koBody);
expect(liveB().form.title).toBe('M');
});
});
@@ -22,6 +22,7 @@ test_files:
- resources/js/core/template-engine/__tests__/troubleshooting-state-setstate.test.ts - resources/js/core/template-engine/__tests__/troubleshooting-state-setstate.test.ts
- resources/js/core/template-engine/__tests__/DynamicRenderer.resizePendingClear.test.tsx - resources/js/core/template-engine/__tests__/DynamicRenderer.resizePendingClear.test.tsx
- resources/js/core/__tests__/G7CoreGlobals.selfManagedPromotion.test.ts - resources/js/core/__tests__/G7CoreGlobals.selfManagedPromotion.test.ts
- resources/js/core/template-engine/__tests__/DynamicRenderer.autoBindingPendingBase.test.tsx
- plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/admin/editor-resize-save-body-integrity.spec.ts - plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/admin/editor-resize-save-body-integrity.spec.ts
axes: axes:
@@ -39,6 +40,9 @@ effects:
- success_toast_only_when_content_actually_saved - success_toast_only_when_content_actually_saved
- resize_clears_pending_local_state - resize_clears_pending_local_state
- selfmanaged_setlocal_writes_canonical_without_render - selfmanaged_setlocal_writes_canonical_without_render
- autobinding_keystroke_preserves_editor_content
- pending_snapshot_carries_forced_overlay
- typed_path_wins_over_forced_previous_value
sub_flows: sub_flows:
- id: control_no_resize - id: control_no_resize
@@ -72,3 +76,19 @@ sub_flows:
effects: effects:
- resize_clears_pending_local_state - resize_clears_pending_local_state
- selfmanaged_setlocal_writes_canonical_without_render - selfmanaged_setlocal_writes_canonical_without_render
- id: autobinding_after_resize
description: |
engine-v1.63.4 (사례 41) — 같은 뿌리의 **다른 방아쇠**. 폭 변경으로 pending 이 비워진 뒤
제목 등 자동바인딩 입력에 키를 치면, performStateUpdate 가 stale 한 저장소 A 스냅샷을
pending 에 싣고 이어지는 setLocal 이 그것을 base 로 채택해 저장소 B 를 통째 교체한다.
**저장 클릭 전에** 이미 편집분이 사라지므로 engine-v1.63.3 의 수정은 이 경로를 지나가지 않는다.
pending 은 getLocal() 이 읽는 "화면과 같은 전체 스냅샷" 이어야 하므로, 렌더러가
extendedDataContext._local 을 만드는 순서(dataContext._local → dynamicState →
__g7ForcedLocalFields)를 그대로 따르게 합성한다. 방금 입력한 경로는 마지막에 다시 얹어
오버레이의 직전 값이 입력을 되돌리지 못하게 한다(사례 11 계열 역방향 결함 방지).
effects:
- autobinding_keystroke_preserves_editor_content
- pending_snapshot_carries_forced_overlay
- typed_path_wins_over_forced_previous_value