fix(core,template): 편집기 image 위젯 값 형태·데이터 연결 값 보호·상속 노드 편집 표면 수정
공개 는 헤더 「로고 이미지」에 이미지를 지정하면 화면이 엑박이 되는 제보였다.
image 위젯은 배경용으로 설계되어 {url,size,repeat,position} 객체를 내보내는데, 값 슬롯이
하나뿐인 apply 경로(propValue/cssVar/단일 styleProp)가 그 객체를 그대로 props 에 기록해
소비 컴포넌트가 [object Object] 를 URL 로 받았다. 예외도 콘솔 오류도 서버 로그도 남지
않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML)이고, 편집기
미리보기는 정상이라 조작 중에는 이상이 보이지 않는다.
방어선을 넷으로 세웠다. 쓰기 축약(공용 헬퍼 scalarizeImageValue 단일 지점, 게이트는 위젯
이름이며 값 형태 sniffing 이 아니다) · 읽기 역조립(표현식 문자열도 되감아 업로드 1클릭에
소실되지 않게 한다) · 런타임 방어(업그레이드 전 화면을 위해 템플릿 Img 가 url 을 해석하고
손상값이면 src 를 아예 붙이지 않는다) · 저장 데이터 백필(업그레이드 스텝). 런타임과 백필은
완전히 같은 엄격 판정식(키 집합 ⊆ 4키 AND url 보유)을 쓴다 — 엔진의 느슨한 판정식을 백필에
이식하면 레이아웃 전수에서 정상 props 2,219건을 파괴한다(실측).
전수조사에서 파생한 인접 결함 넷을 함께 고쳤다.
- number 위젯이 코어 레지스트리에 미등록이라 「탭 표시 게시판 수」 같은 컨트롤이
「지원하지 않는 컨트롤」로 폴백해 편집 자체가 불가했다. nodeKey apply 는 coreProps 가
선언만 하고 엔진 switch 에 case 가 없어 무음 no-op 이었다.
- 상속(base)·주입(extension) 노드 중 바인딩을 가진 것이 data_bound 로 분류돼 편집이
열려 있었는데, 저장 마스킹이 그 노드를 통째로 폐기하므로 편집분이 오류도 경고도 없이
사라졌다(저장은 200 이고 history 는 clear 돼 undo 도 불가). 출처 잠금이 항상 우선하도록
판정 순서를 통일하고, 단일 판정 헬퍼로 인라인 편집·복제·Delete·잘라내기·드래그 commit
까지 전 표면을 같은 기준으로 막았다.
- prop 자리의 표현식 값을 위젯이 해석하지 못해 빈 컨트롤로 보이고, 조작하는 순간 환경설정
과의 연결이 소리 없이 끊겼다. 판정·배지·잠금·해제·복구를 ControlRenderer 공용 게이트
한 곳으로 올려 신규 위젯에도 자동 적용되게 했다. 「직접 지정으로 바꾸기」에는 「되돌리기」를
동반해 편도가 되지 않게 한다.
- 편집기 모드에서 updateTemplateData 가 빈 레이아웃으로 같은 reactRoot 에 두 번째 커밋을
걸어 편집기 트리를 통째로 제거했다. renderTemplate 의 편집기 분기가 비동기라 부팅 중
setGlobalState 가 그 커밋 뒤에 도착할 때만 발현하는 경합이었다.
This commit is contained in:
@@ -160,7 +160,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 0개 | — |
|
||||
| Vitest | 147개 | `vitest.config.ts` |
|
||||
| Vitest | 148개 | `vitest.config.ts` |
|
||||
| Playwright | 8개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 4개 | `tests/scenarios` |
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
### Fixed
|
||||
|
||||
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
|
||||
- 화면 편집기로 지정한 로고가 깨져 보이던 문제를 수정했습니다. 이미지 주소 자리에 알 수 없는 값이 저장된 사이트에서도 이제 그림이 정상 표시되며, 주소를 해석할 수 없으면 이미지 대신 사이트 이름이 표시됩니다. (#135 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
|
||||
## [1.1.3] - 2026-09-06
|
||||
|
||||
|
||||
+11
-11
File diff suppressed because one or more lines are too long
@@ -2,18 +2,50 @@ import React from 'react';
|
||||
|
||||
export interface ImgProps extends React.ImgHTMLAttributes<HTMLImageElement> {}
|
||||
|
||||
/**
|
||||
* `image` 위젯 값 객체의 키 집합 — 레이아웃 편집기가 만들던 `{url,size,repeat,position}`.
|
||||
*
|
||||
* 편집기는 이제 단일 값 슬롯에 url 문자열만 기록하고(코어 변환 계층), 이미 잘못 저장된
|
||||
* 레이아웃은 업그레이드 스텝이 정정한다. 이 상수는 **업그레이드 전 화면**을 위한
|
||||
* 런타임 방어이며, 판정 강도를 백필과 **완전히 같게** 유지한다 — 두 방어선의 기준이
|
||||
* 어긋나면 한쪽만 통과하는 값이 생긴다.
|
||||
*/
|
||||
const IMAGE_VALUE_KEYS = ['url', 'size', 'repeat', 'position'] as const;
|
||||
|
||||
/**
|
||||
* `src` 로 들어온 값에서 실제 이미지 URL 을 해석한다.
|
||||
*
|
||||
* 문자열이면 그대로(회귀 0). 이미지 값 객체(키 집합 ⊆ 4키 **AND** `url` 보유)면 그 url.
|
||||
* 그 외(배열·숫자·불리언·4키 밖 키를 가진 객체·비문자열 url)는 `undefined` 를 돌려주어
|
||||
* `src` 속성을 **아예 붙이지 않는다** — `src="[object Object]"`(엑박)도
|
||||
* `src=""`(현재 문서 재요청)도 만들지 않는다.
|
||||
*
|
||||
* `ImgProps` 를 넓히지 않는다(`src?: string` 계약 유지) — export 하면 그 자체가 공개
|
||||
* 계약이 되어 느슨한 타입이 다시 새어 나간다.
|
||||
*/
|
||||
function resolveImageSrc(src: unknown): string | undefined {
|
||||
if (typeof src === 'string') return src === '' ? undefined : src;
|
||||
if (!src || typeof src !== 'object' || Array.isArray(src)) return undefined;
|
||||
const keys = Object.keys(src as Record<string, unknown>);
|
||||
if (!keys.every((k) => (IMAGE_VALUE_KEYS as readonly string[]).includes(k))) return undefined;
|
||||
const url = (src as { url?: unknown }).url;
|
||||
return typeof url === 'string' && url !== '' ? url : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 이미지 컴포넌트
|
||||
*/
|
||||
export const Img: React.FC<ImgProps> = ({
|
||||
className = '',
|
||||
alt = '',
|
||||
src,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
alt={alt}
|
||||
src={resolveImageSrc(src as unknown)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @scenario apply_type=propValue, consumer=img_src, storage_scope=template_layouts, stored_shape=string, widget_output=scalar_string
|
||||
// @scenario apply_type=propValue, consumer=mobilenav_logo, storage_scope=template_layouts, stored_shape=absent, widget_output=image_object
|
||||
/**
|
||||
* Img.test.tsx — `src` 값 형태 방어 (공개 #135 런타임 방어선)
|
||||
*
|
||||
* 레이아웃 편집기의 `image` 위젯은 `{url,size,repeat,position}` 객체를 내보내는데,
|
||||
* 그 값이 `propValue` 로 컴포넌트 prop 에 그대로 기록되던 시기가 있었다. 그렇게 저장된
|
||||
* 레이아웃은 `<Img src={객체}>` 가 되어 브라우저가 `[object Object]` 를 URL 로 해석했고,
|
||||
* SPA catch-all 때문에 404 조차 아니라 200(HTML)을 받아 **엑박만 남고 아무 오류도
|
||||
* 남지 않았다.**
|
||||
*
|
||||
* 편집기·백필이 고쳐졌어도 업그레이드 전 화면은 그대로이므로 이 방어가 필요하다.
|
||||
* 판정 강도는 백필 마이그레이션과 **완전히 같다**(키 집합 ⊆ 4키 AND url 문자열) —
|
||||
* 두 방어선의 기준이 어긋나면 한쪽만 통과하는 값이 생긴다.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Img } from '../Img';
|
||||
|
||||
/** 렌더된 <img> 의 src 속성 원문(미부착이면 null) */
|
||||
function srcAttr(container: HTMLElement): string | null {
|
||||
const img = container.querySelector('img');
|
||||
expect(img).toBeTruthy();
|
||||
return img!.getAttribute('src');
|
||||
}
|
||||
|
||||
describe('Img — src 값 형태 방어', () => {
|
||||
/** @effects img_string_src_passes_through_unchanged */
|
||||
it('1 정상 문자열은 그대로 통과한다 (회귀 0)', () => {
|
||||
const { container } = render(<Img src="/img/logo.png" />);
|
||||
expect(srcAttr(container)).toBe('/img/logo.png');
|
||||
});
|
||||
|
||||
/** @effects img_corrupted_value_attaches_no_src_neither_object_object_nor_empty_string */
|
||||
it('2 빈 문자열은 src 를 붙이지 않는다 (현재 문서 재요청 차단)', () => {
|
||||
const { container } = render(<Img src="" />);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
it('3 src 미전달도 예외 없이 미부착', () => {
|
||||
const { container } = render(<Img alt="x" />);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
/** @effects img_image_value_object_resolves_to_its_url */
|
||||
it('4 이미지 값 객체(4키 전부) → url 만 채택', () => {
|
||||
const value = {
|
||||
url: '/api/attachment/X',
|
||||
size: 'cover',
|
||||
repeat: 'no-repeat',
|
||||
position: 'center',
|
||||
};
|
||||
const { container } = render(<Img src={value as never} />);
|
||||
expect(srcAttr(container)).toBe('/api/attachment/X');
|
||||
});
|
||||
|
||||
it('5 부분 키(url 만) 객체도 채택', () => {
|
||||
const { container } = render(<Img src={{ url: '/a.png' } as never} />);
|
||||
expect(srcAttr(container)).toBe('/a.png');
|
||||
});
|
||||
|
||||
it('6 url 이 빈 문자열이면 미부착 — [object Object] 도 빈 문자열도 아니다', () => {
|
||||
const { container } = render(
|
||||
<Img src={{ url: '', size: 'cover', repeat: 'no-repeat', position: 'center' } as never} />,
|
||||
);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
it('7 url 키가 없는 객체는 미부착 (정상 props 와 정적 구분 불가 → 건드리지 않는다)', () => {
|
||||
const { container } = render(
|
||||
<Img src={{ size: 'cover', repeat: 'no-repeat', position: 'center' } as never} />,
|
||||
);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
it('8 url 이 비문자열이면 미부착', () => {
|
||||
const { container } = render(<Img src={{ url: { deep: 1 }, size: 'cover' } as never} />);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
/** @effects img_object_with_key_outside_the_four_is_rejected_same_strictness_as_backfill */
|
||||
it('9 4키 밖 키가 섞이면 미부착 — 백필과 동일 엄격도', () => {
|
||||
const { container } = render(<Img src={{ url: '/a.png', label: 'x' } as never} />);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
it('10 배열은 미부착', () => {
|
||||
const { container } = render(<Img src={['/a.png'] as never} />);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
it('11 숫자·불리언은 예외 없이 미부착', () => {
|
||||
expect(srcAttr(render(<Img src={42 as never} />).container)).toBeNull();
|
||||
expect(srcAttr(render(<Img src={true as never} />).container)).toBeNull();
|
||||
});
|
||||
|
||||
/** @effects img_other_attributes_pass_through_spread_regression_zero */
|
||||
it('12 다른 속성은 그대로 통과한다 (스프레드 회귀 0)', () => {
|
||||
const { container } = render(
|
||||
<Img src="/a.png" alt="로고" className="h-8" loading="lazy" width={32} />,
|
||||
);
|
||||
const img = container.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).toBe('/a.png');
|
||||
expect(img.getAttribute('alt')).toBe('로고');
|
||||
expect(img.getAttribute('class')).toBe('h-8');
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
expect(img.getAttribute('width')).toBe('32');
|
||||
});
|
||||
|
||||
it('13 alt 기본값은 빈 문자열로 유지된다', () => {
|
||||
const { container } = render(<Img src="/a.png" />);
|
||||
expect(container.querySelector('img')!.getAttribute('alt')).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user