fix(engine): 반복 렌더 경로의 리터럴 단일 바인딩 판정 누락 해소

판정 통일(engine-v1.55.0)에서 리터럴 해석을 BindingShape 한 곳으로 모았으나
renderItemChildren 만 `hasPipes → isComplexExpression → 경로 탐색` 3분기를 직접
갈라 리터럴을 몰랐다. 그 결과 같은 `{{true}}` 가 조건 자리에서는 true,
목록 셀·카드 등 반복 렌더 prop 자리에서는 undefined 로 갈렸다 —
통일이 없애려던 비대칭이 이 지점에만 남아 있었다.

형태 판정을 resolveSingleBindingValue 에 위임해 다른 경로와 같은 규칙을 쓴다.
같은 지점이 빈 바인딩(`{{}}`)도 몰라 경로 탐색으로 보내던 것을 함께 봉인했다.

브라우저 실측(배포 번들의 G7Core.renderItemChildren 직접 호출)으로 발견했고,
저장소 레이아웃의 리터럴 단일 바인딩 60건은 전부 if/condition 자리라
화면 표시에는 변화가 없다.

SEO 패리티: 봇 측 ExpressionEvaluator 는 이미 리터럴을 해석하고 있었으므로
이 수정은 React 경로를 봇 경로에 맞춘 것이다 (파일 헤더에 면제 사유 기재).
This commit is contained in:
HeuJung
2026-08-04 10:08:00 +09:00
parent 60cccbf62d
commit 65378fdeb6
6 changed files with 120 additions and 41 deletions
+1
View File
@@ -118,6 +118,7 @@
- 계산식에 날짜·숫자 서식을 함께 걸면 원본 값이 바뀌어도 처음 계산된 값이 계속 표시되던 문제를 수정했습니다.
- 목록 칸이나 합계 행 안에서 번역 문구가 문장 중간에 들어간 경우 번역되지 않고 원본 키가 그대로 보이던 문제, 그리고 일부 표시 값에 `{{ }}` 기호가 섞여 나오던 문제를 수정했습니다.
- 목록 칸이나 합계 행에서 '번역하지 않고 원문 그대로 표시'로 지정한 값에 보이지 않는 특수문자가 함께 출력되던 문제를 수정했습니다. 화면에서는 빈칸이나 깨진 글자로 보였고, 값을 복사해 붙여넣을 때 따라붙었습니다.
- 목록 표·카드의 셀에서 `true`/`false` 같은 값을 직접 쓴 자리가 비어 보이던 문제를 수정했습니다. 같은 값을 표시 조건에 쓰면 정상 동작해서, 같은 작성이 놓인 자리에 따라 갈렸습니다.
- 목록 화면에서 글이나 항목을 열어 보고 돌아올 때 보고 있던 페이지·검색어·필터가 사라지던 문제를 화면 엔진 차원에서 함께 수정했습니다. '현재 주소의 조건을 유지한다'고 지정해도 덧붙일 값이 없으면 유지가 아예 동작하지 않아 조건이 통째로 사라지던 동작을 바로잡았습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
#### 확장·데이터베이스 안정성
File diff suppressed because one or more lines are too long
@@ -5,6 +5,13 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.56.4] - 2026-08-04
### Fixed
- 반복 렌더 경로(`renderItemChildren`)가 리터럴 단일 바인딩(`{{true}}` / `{{false}}` / `{{null}}` / `{{undefined}}`)을 경로로 탐색해 값이 `undefined` 가 됐다. 판정 통일(engine-v1.55.0)에서 리터럴을 `BindingShape` 한 곳으로 모았지만 이 경로만 `hasPipes → isComplexExpression → 경로 탐색` 3분기를 직접 갈라 리터럴을 몰랐고, 그 결과 같은 `{{true}}` 가 조건 자리에서는 `true`, 목록 셀·카드 등 반복 렌더 prop 자리에서는 `undefined` 로 갈렸다 — 통일이 없애려던 비대칭이 이 지점에만 남아 있었다. 형태 판정을 `resolveSingleBindingValue` 에 위임해 다른 경로와 같은 규칙을 쓴다.
- 저장소 레이아웃의 리터럴 단일 바인딩 60건은 전부 `if`/`condition` 자리라 화면 표시에는 변화가 없다. 브라우저 실측으로 발견했고(배포 번들의 `G7Core.renderItemChildren` 직접 호출), 같은 지점이 빈 바인딩(`{{}}`)도 몰라 경로 탐색으로 보내던 것을 함께 봉인했다.
## [engine-v1.56.3] - 2026-08-02
### Fixed
@@ -1,3 +1,7 @@
// audit:allow seo-renderer-parity-sync 이번 변경은 노드 키·컨텍스트 처리 추가가 아니라
// 리터럴 판정을 BindingShape 로 위임한 것이다. 봇(SEO) 측 ExpressionEvaluator 는
// 이미 'true'/'false'/'null' 을 리터럴로 해석하고 있었으므로(app/Seo/ExpressionEvaluator.php),
// 이 수정은 React 경로를 봇 경로에 맞춘 것이며 패리티를 좁힌다. 봇 측 대응 변경 없음.
/**
* 렌더링 관련 헬퍼 함수 모듈
*
@@ -762,34 +766,34 @@ export function renderItemChildren(
effectivePath = singleBindingPath.slice(RAW_PREFIX.length);
}
let result;
if (hasPipes(effectivePath)) {
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다.
// isComplexExpression 은 `|` 를 연산자로 보아 evaluateExpression 으로 보내는데,
// 그 경로에서는 `value | pipe` 가 JS 비트 OR 로 평가되어
// 인자 있는 파이프는 예외(값 소실), 인자 없는 파이프는 조용한 오답이 된다.
// DynamicRenderer 의 text 처리와 동일한 분기 순서를 유지한다.
// raw: 접두사는 아래 공통 분기(wrapRawDeep)가 담당하므로 여기서는 제거된
// effectivePath 만 넘긴다 — 원본 value 를 넘기면 raw 마커가 이중으로 감싸진다.
// @since engine-v1.54.3
// 반복 렌더의 props 값도 원본 타입이 필요하다 (문자열 서식 미적용).
// @since engine-v1.54.9
try {
result = bindingEngine.evaluatePipeExpression(effectivePath, context, { skipCache: true });
} catch (error) {
logger.warn('renderItemChildren: 파이프 표현식 평가 실패:', error);
return undefined;
}
} else if (isComplexExpression(effectivePath)) {
try {
result = bindingEngine.evaluateExpression(effectivePath, context);
} catch (error) {
// 형태 판정은 `BindingShape` 단일 출처에 맡긴다. 종전에는 이 지점만
// `hasPipes → isComplexExpression → 경로 탐색` 3분기를 직접 갈라, 판정 통일
// (engine-v1.55.0)이 모아 둔 **리터럴**(`true`/`false`/`null`/`undefined`)을
// 이 경로만 몰랐다. 그래서 `{{true}}` 가 조건 자리에서는 `true`, 반복 렌더 prop
// 자리에서는 경로 탐색으로 새어 `undefined` 가 됐다 — 통일이 없애려던 바로 그
// 비대칭이 여기에만 남아 있었다.
//
// 실행 정책은 종전과 같다: 파이프는 원본 타입 보존(engine-v1.54.9), 평가 실패는
// 경고 후 `undefined`(값 소실이 화면 전체를 깨뜨리지 않도록).
// raw: 접두사는 아래 공통 분기(wrapRawDeep)가 담당하므로 제거된 effectivePath 만
// 넘긴다 — 원본 value 를 넘기면 raw 마커가 이중으로 감싸진다. @since engine-v1.54.3
// @since engine-v1.56.4
let evaluationFailed = false;
let result = resolveSingleBindingValue(effectivePath, context, bindingEngine, {
skipCache: true,
onError: (error) => {
logger.warn('renderItemChildren: 표현식 평가 실패:', error);
evaluationFailed = true;
return undefined;
}
} else {
// skipCache: true - 반복 렌더링에서 같은 경로가 다른 값을 가져야 함
result = bindingEngine.resolve(effectivePath, context, { skipCache: true });
},
onEmpty: () => {
logger.warn('renderItemChildren: 빈 바인딩 `{{}}` 은 해석하지 않습니다.');
},
});
if (evaluationFailed) {
return undefined;
}
// 표현식 평가 결과가 $t: 접두사 문자열이면 번역 처리
@@ -307,4 +307,38 @@ describe('renderItemChildren — 반복 렌더 경로 기능 격차', () => {
expect(JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!)).toBe(42);
view.unmount();
});
/**
* 리터럴 단일 바인딩 (engine-v1.56.4)
*
* 판정 통일(engine-v1.55.0)에서 리터럴을 `BindingShape` 한 곳으로 모았지만, 이 경로만
* `hasPipes → isComplexExpression → 경로 탐색` 3분기를 직접 갈라 리터럴을 몰랐다.
* 그래서 `{{true}}` 가 조건 자리에서는 `true`, 반복 렌더 prop 자리에서는 `undefined` 로
* 갈렸다 — 통일이 없애려던 바로 그 비대칭이 이 경로에만 남아 있었다.
*/
it.each([
['{{true}}', true],
['{{false}}', false],
['{{null}}', null],
])('리터럴 %s 이 prop 자리에서 경로 탐색으로 새지 않는다', (expr, expected) => {
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', props: { value: expr } }],
{ row: { id: 1 } },
);
expect(JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!)).toBe(expected);
view.unmount();
});
it('리터럴과 같은 이름의 컨텍스트 경로보다 리터럴이 우선한다', () => {
// `true` 라는 이름의 컨텍스트 키가 있어도 `{{true}}` 는 리터럴이다 —
// 조건 평가 경로와 같은 규칙.
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', props: { value: '{{true}}' } }],
{ true: '경로값', row: { id: 1 } },
);
expect(JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!)).toBe(true);
view.unmount();
});
});
@@ -110,6 +110,39 @@ test.describe('렌더 경로 간 바인딩 해석 대칭', () => {
expect(String(value)).not.toContain('{{');
});
// @scenario binding_literal_symmetry
// @effects same_expression_same_value_across_paths
test('리터럴이 반복 렌더와 조건 판정에서 같은 값을 본다', async ({ page }) => {
const result = await page.evaluate(() => {
const g7 = (window as any).G7Core;
const Span = function Span() { return null; };
const context = { row: { id: 1 } };
const viaRepeat = (expr: string) =>
(g7.renderItemChildren(
[{ type: 'basic', name: 'Span', props: { title: expr } }],
context,
{ Span },
'e2e-literal',
) as any[])[0]?.props?.title;
return {
repeatTrue: viaRepeat('{{true}}'),
repeatFalse: viaRepeat('{{false}}'),
repeatNull: viaRepeat('{{null}}'),
condTrue: g7.evaluateCondition('{{true}}', context),
condFalse: g7.evaluateCondition('{{false}}', context),
};
});
// 수정 전: 반복 렌더 경로만 리터럴을 몰라 경로 탐색으로 새어 전부 undefined 였다.
// 조건 경로는 리터럴을 알았으므로 같은 작성이 자리에 따라 갈렸다.
expect(result.repeatTrue).toBe(true);
expect(result.repeatFalse).toBe(false);
expect(result.repeatNull).toBeNull();
expect(result.repeatTrue).toBe(result.condTrue);
expect(result.repeatFalse).toBe(result.condFalse);
});
// @scenario binding_empty_expression
// @effects same_expression_same_value_across_paths
test('빈 바인딩이 컨텍스트 객체 전체를 노출하지 않는다', async ({ page }) => {