@ihabdevteam/core 0.170.0 → 0.172.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { ComponentType } from 'react';
1
+ import { ComponentType } from 'react';
2
2
  import { Context } from 'react';
3
3
  import { CSSProperties } from 'react';
4
4
  import { Dispatch } from 'react';
@@ -10,6 +10,7 @@ import { ForwardRefExoticComponent } from 'react';
10
10
  import type { HTMLAttributes } from 'react';
11
11
  import { JSX } from 'react';
12
12
  import { KeyboardEvent as KeyboardEvent_2 } from 'react';
13
+ import { LazyExoticComponent } from 'react';
13
14
  import { MouseEvent as MouseEvent_2 } from 'react';
14
15
  import { default as React_2 } from 'react';
15
16
  import * as React_3 from 'react';
@@ -1555,7 +1556,7 @@ export declare function copyText(text: string): Promise<boolean>;
1555
1556
  *
1556
1557
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1557
1558
  */
1558
- export declare const CORE_VERSION = "0.170.0";
1559
+ export declare const CORE_VERSION = "0.172.0";
1559
1560
 
1560
1561
  /** @public */
1561
1562
  declare const coreDefault: Record<string, unknown>;
@@ -2868,6 +2869,9 @@ export declare function isThisWeekPick({ rank, voteCount, quota }: {
2868
2869
  /** @public */
2869
2870
  export declare function isTrainingDataLoaded(): boolean;
2870
2871
 
2872
+ /** 연락처가 `formatPhone` 이 끝까지 채워 낸 모양인가 — 규칙은 `PHONE_REGEX`. @public */
2873
+ export declare function isValidPhone(phone: string): boolean;
2874
+
2871
2875
  /** @public 단어가 해당 타입의 문제(정답) 단어로 구조적으로 유효한가. 제약 없는 타입은 항상 true. */
2872
2876
  export declare function isValidQuestionWord(typeKey: WordTrainingTypeKey, word: string): boolean;
2873
2877
 
@@ -3013,6 +3017,29 @@ export declare interface LandingHeaderProps {
3013
3017
  };
3014
3018
  }
3015
3019
 
3020
+ /**
3021
+ * 화면을 늦게 받아 오는 자리 — `lazy()` 를 그냥 쓰지 않고 이걸 쓴다.
3022
+ *
3023
+ * 맨 `lazy(() => import(…))` 는 **모듈이 undefined 로 와도 그대로 `.default` 를 읽는다.**
3024
+ * 그 일이 실제로 난다: 배포 skew 복구가 `vite:preloadError` 를 `preventDefault()` 하면
3025
+ * Vite 헬퍼의 `catch` 가 아무것도 안 던지고 undefined 로 resolve 한다(자세한 것은
3026
+ * `chunkSkew.ts`). 그러면 새로고침이 이미 걸려 있는데도 그 틈에 React 가
3027
+ * `undefined.default` 를 읽어 **오류 화면이 한 번 뜨고 가짜 보고가 남는다.**
3028
+ *
3029
+ * 두 가지를 한다.
3030
+ * 1. **새로고침이 예약돼 있으면 아무것도 결정하지 않는다** — 영영 resolve 하지 않는
3031
+ * 약속을 돌려 Suspense 를 그대로 둔다. 곧 새 판이 뜬다.
3032
+ * 2. 그게 아닌데 모듈이 이상하면 **어느 화면인지 적어** 던진다. 지금까지 이 오류는
3033
+ * 스택이 통째로 vendor-react 안이라(minify 된 `q`) 어느 화면에서 났는지 알 수 없었다.
3034
+ *
3035
+ * Vite 가 아닌 번들러(Next 등)에서는 `vite:preloadError` 가 오지 않으니 이 감싸개는
3036
+ * "모듈이 이상하면 어느 화면인지 적어 던진다" 만 한다 — 그것만으로도 맨 `lazy` 보다 낫다.
3037
+ * @public
3038
+ */
3039
+ export declare function lazyPage<T extends ComponentType<any>>(name: string, load: () => Promise<{
3040
+ default: T;
3041
+ } | undefined>): LazyExoticComponent<T>;
3042
+
3016
3043
  declare type LeaderboardFn = (gameKey: string, limit?: number) => Promise<LeaderboardRow[] | null>;
3017
3044
 
3018
3045
  /**
@@ -4143,6 +4170,25 @@ export declare interface PersonCardProps {
4143
4170
  className?: string;
4144
4171
  }
4145
4172
 
4173
+ /**
4174
+ * 연락처가 **`formatPhone` 이 끝까지 채워 낸 모양** 인가.
4175
+ *
4176
+ * 만드는 쪽(`formatPhone`)은 코어인데 받는 쪽(형식 검사)은 소비자마다 한 벌씩 두고 있었다 —
4177
+ * 말귀 회원앱·어드민에 같은 정규식이 한 벌씩 있었고, 둘이 같은지는 짝 시험이 지켰지만
4178
+ * **코어가 만든 모양과 맞는지는 아무도 안 쟀다.** 실제로 코어가 050X·0303(앞자리 네 자리)을
4179
+ * 받았을 때 그 두 벌도 손으로 함께 고쳐야 했다. 그래서 짝을 여기 둔다 — 아래 시험이
4180
+ * "formatPhone 이 끝까지 채운 값은 늘 통과한다" 를 못 박는다.
4181
+ *
4182
+ * `formatPhone` 은 **덜 채운 입력을 그대로 돌려준다**(`010-12`). 그래서 빈값 검사만으로는
4183
+ * 모자라고, 이 함수가 그 자리를 맡는다.
4184
+ *
4185
+ * 받는 꼴 — 휴대폰·지역번호(02·0NN)·050X 안심번호·0303 착신전환·**1588 등 대표번호**.
4186
+ * 대표번호는 `formatPhone` 이 `1588-1234` 로 만들어 내므로 여기서도 받는다
4187
+ * (0 으로 시작하는 것만 받던 소비자 정규식은 그 번호를 거절했다 — 코어가 만든 모양을 코어가 거절하는 꼴).
4188
+ * @public
4189
+ */
4190
+ export declare const PHONE_REGEX: RegExp;
4191
+
4146
4192
  /**
4147
4193
  * 훈련할 음절(초성/모음/종성)을 고르는 FilterPanel 셸(shell) — 섹션 자체는 훈련 타입별 조건(caps)에
4148
4194
  * 따라 호출부(useWordTrainingFilter)가 구성해 넘긴다. WordTrainingFilterPanels의 [훈련 음절] 패널이 원 출처.
@@ -5029,6 +5075,44 @@ export declare interface Recommendation {
5029
5075
  */
5030
5076
  export declare function recommendTables(tables: readonly TableCandidate[], weakLabels: readonly string[], limit?: number): Recommendation[];
5031
5077
 
5078
+ /**
5079
+ * 배포 skew 복구 — 그리고 그 복구가 내던 오류 화면.
5080
+ *
5081
+ * ── 무엇이 문제였나 ──
5082
+ * 새 배포로 청크 해시가 바뀌면 캐시된 옛 index.html 이 사라진 청크를 부른다. Vite 는
5083
+ * `vite:preloadError` 를 던지고, 우리는 그때 한 번 새로고침해 새 index.html 을 받는다.
5084
+ * 여기까지는 뜻대로 돈다.
5085
+ *
5086
+ * 그런데 그 이벤트를 `preventDefault()` 하면 Vite 헬퍼가 이렇게 한다(vite 소스 그대로):
5087
+ *
5088
+ * function handlePreloadError(err) {
5089
+ * const e = new Event('vite:preloadError', { cancelable: true })
5090
+ * window.dispatchEvent(e)
5091
+ * if (!e.defaultPrevented) throw err // ← 막으면 안 던진다
5092
+ * }
5093
+ * return baseModule().catch(handlePreloadError) // ← 안 던지니 undefined 로 resolve
5094
+ *
5095
+ * **`location.reload()` 는 곧바로 멈추지 않는다.** 새 판이 뜨기 전까지 React 는 계속
5096
+ * 그리고, `React.lazy` 는 resolve 된 `undefined` 의 `.default` 를 읽는다 —
5097
+ * `Cannot read properties of undefined (reading 'default')`. 운영에서 이 오류가
5098
+ * 07-09 ~ 08-28 동안 **아홉 사람에게 열세 번** 났다(/today·/customers·/my-word-table·
5099
+ * /word-pairs·/word-management·/training-store — 스택은 전부 vendor-react 의 lazy 초기화).
5100
+ *
5101
+ * 즉 복구는 되는데 그 길목에서 **오류 화면을 한 번 띄우고 가짜 보고를 남기고** 있었다.
5102
+ *
5103
+ * ── 어떻게 고치나 ──
5104
+ * 새로고침을 걸어 둔 동안에는 `lazy` 가 아무것도 결정하지 않게 한다(`lazyPage` 가
5105
+ * 영영 resolve 하지 않는 약속을 돌려 Suspense 를 그대로 둔다). 그러려면 "지금 새로고침이
5106
+ * 예약돼 있나"를 물을 수 있어야 해서, 그 판단을 여기 한 곳에 둔다.
5107
+ */
5108
+ /**
5109
+ * 배포 skew 로 판단되면 1회만 새로고침해 새 index.html 을 받는다.
5110
+ * 앱이 `vite:preloadError`(또는 청크 로드 실패)를 받았을 때 부른다.
5111
+ * @returns 새로고침을 걸었으면 true. 이미 최근에 걸었으면 false(진짜 오류로 넘긴다).
5112
+ * @public
5113
+ */
5114
+ export declare function recoverFromSkew(now?: number): boolean;
5115
+
5032
5116
  /** getBoundingClientRect()만 있으면 되는 최소 인터페이스(주로 이벤트의 currentTarget). */
5033
5117
  declare type Rectable = {
5034
5118
  getBoundingClientRect(): DOMRect;
@@ -5875,6 +5959,15 @@ export declare interface SideNavProps {
5875
5959
  onAppModeChange?: (mode: 'malgwi' | 'manager') => void;
5876
5960
  }
5877
5961
 
5962
+ /**
5963
+ * 새로고침을 걸어 두었고 아직 새 판이 안 떴는가.
5964
+ * @public
5965
+ *
5966
+ * `location.reload()` 가 비동기라 그 사이에 React 가 계속 도는데, 그동안 화면에
5967
+ * 무엇을 그릴지 정하는 데 쓴다 — **아무것도 안 그리는 것이 맞다.** 곧 판이 바뀐다.
5968
+ */
5969
+ export declare function skewReloadPending(now?: number): boolean;
5970
+
5878
5971
  /**
5879
5972
  * 가로/세로 슬라이더. 접근성·키보드 지원을 위해 input[type=range] 기반.
5880
5973
  * 채워진 부분은 --slider-pct CSS 변수로 그라데이션 처리한다.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 배포 skew 복구 — 그리고 그 복구가 내던 오류 화면.
3
+ *
4
+ * ── 무엇이 문제였나 ──
5
+ * 새 배포로 청크 해시가 바뀌면 캐시된 옛 index.html 이 사라진 청크를 부른다. Vite 는
6
+ * `vite:preloadError` 를 던지고, 우리는 그때 한 번 새로고침해 새 index.html 을 받는다.
7
+ * 여기까지는 뜻대로 돈다.
8
+ *
9
+ * 그런데 그 이벤트를 `preventDefault()` 하면 Vite 헬퍼가 이렇게 한다(vite 소스 그대로):
10
+ *
11
+ * function handlePreloadError(err) {
12
+ * const e = new Event('vite:preloadError', { cancelable: true })
13
+ * window.dispatchEvent(e)
14
+ * if (!e.defaultPrevented) throw err // ← 막으면 안 던진다
15
+ * }
16
+ * return baseModule().catch(handlePreloadError) // ← 안 던지니 undefined 로 resolve
17
+ *
18
+ * **`location.reload()` 는 곧바로 멈추지 않는다.** 새 판이 뜨기 전까지 React 는 계속
19
+ * 그리고, `React.lazy` 는 resolve 된 `undefined` 의 `.default` 를 읽는다 —
20
+ * `Cannot read properties of undefined (reading 'default')`. 운영에서 이 오류가
21
+ * 07-09 ~ 08-28 동안 **아홉 사람에게 열세 번** 났다(/today·/customers·/my-word-table·
22
+ * /word-pairs·/word-management·/training-store — 스택은 전부 vendor-react 의 lazy 초기화).
23
+ *
24
+ * 즉 복구는 되는데 그 길목에서 **오류 화면을 한 번 띄우고 가짜 보고를 남기고** 있었다.
25
+ *
26
+ * ── 어떻게 고치나 ──
27
+ * 새로고침을 걸어 둔 동안에는 `lazy` 가 아무것도 결정하지 않게 한다(`lazyPage` 가
28
+ * 영영 resolve 하지 않는 약속을 돌려 Suspense 를 그대로 둔다). 그러려면 "지금 새로고침이
29
+ * 예약돼 있나"를 물을 수 있어야 해서, 그 판단을 여기 한 곳에 둔다.
30
+ */
31
+ /**
32
+ * 배포 skew 로 판단되면 1회만 새로고침해 새 index.html 을 받는다.
33
+ * 앱이 `vite:preloadError`(또는 청크 로드 실패)를 받았을 때 부른다.
34
+ * @returns 새로고침을 걸었으면 true. 이미 최근에 걸었으면 false(진짜 오류로 넘긴다).
35
+ * @public
36
+ */
37
+ export declare function recoverFromSkew(now?: number): boolean;
38
+ /**
39
+ * 새로고침을 걸어 두었고 아직 새 판이 안 떴는가.
40
+ * @public
41
+ *
42
+ * `location.reload()` 가 비동기라 그 사이에 React 가 계속 도는데, 그동안 화면에
43
+ * 무엇을 그릴지 정하는 데 쓴다 — **아무것도 안 그리는 것이 맞다.** 곧 판이 바뀐다.
44
+ */
45
+ export declare function skewReloadPending(now?: number): boolean;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 배포 skew 복구 — 그리고 그 복구가 내던 오류 화면.
3
+ *
4
+ * ── 무엇이 문제였나 ──
5
+ * 새 배포로 청크 해시가 바뀌면 캐시된 옛 index.html 이 사라진 청크를 부른다. Vite 는
6
+ * `vite:preloadError` 를 던지고, 우리는 그때 한 번 새로고침해 새 index.html 을 받는다.
7
+ * 여기까지는 뜻대로 돈다.
8
+ *
9
+ * 그런데 그 이벤트를 `preventDefault()` 하면 Vite 헬퍼가 이렇게 한다(vite 소스 그대로):
10
+ *
11
+ * function handlePreloadError(err) {
12
+ * const e = new Event('vite:preloadError', { cancelable: true })
13
+ * window.dispatchEvent(e)
14
+ * if (!e.defaultPrevented) throw err // ← 막으면 안 던진다
15
+ * }
16
+ * return baseModule().catch(handlePreloadError) // ← 안 던지니 undefined 로 resolve
17
+ *
18
+ * **`location.reload()` 는 곧바로 멈추지 않는다.** 새 판이 뜨기 전까지 React 는 계속
19
+ * 그리고, `React.lazy` 는 resolve 된 `undefined` 의 `.default` 를 읽는다 —
20
+ * `Cannot read properties of undefined (reading 'default')`. 운영에서 이 오류가
21
+ * 07-09 ~ 08-28 동안 **아홉 사람에게 열세 번** 났다(/today·/customers·/my-word-table·
22
+ * /word-pairs·/word-management·/training-store — 스택은 전부 vendor-react 의 lazy 초기화).
23
+ *
24
+ * 즉 복구는 되는데 그 길목에서 **오류 화면을 한 번 띄우고 가짜 보고를 남기고** 있었다.
25
+ *
26
+ * ── 어떻게 고치나 ──
27
+ * 새로고침을 걸어 둔 동안에는 `lazy` 가 아무것도 결정하지 않게 한다(`lazyPage` 가
28
+ * 영영 resolve 하지 않는 약속을 돌려 Suspense 를 그대로 둔다). 그러려면 "지금 새로고침이
29
+ * 예약돼 있나"를 물을 수 있어야 해서, 그 판단을 여기 한 곳에 둔다.
30
+ */
31
+ const SKEW_KEY = 'chunk-reload-ts';
32
+ /** 이 시간 안에 다시 실패하면 배포 skew 가 아니라 진짜 오류로 본다(무한 새로고침 방지). */
33
+ const COOLDOWN_MS = 10_000;
34
+ function 마지막시각() {
35
+ try {
36
+ return Number(sessionStorage.getItem(SKEW_KEY) || 0);
37
+ }
38
+ catch {
39
+ /* 사생활 보호 모드 등에서 sessionStorage 가 막힌다. 못 읽으면 '없음' 으로 본다 —
40
+ 그러면 새로고침을 한 번 더 하게 될 뿐, 화면이 멈추지는 않는다. */
41
+ return 0;
42
+ }
43
+ }
44
+ /**
45
+ * 배포 skew 로 판단되면 1회만 새로고침해 새 index.html 을 받는다.
46
+ * 앱이 `vite:preloadError`(또는 청크 로드 실패)를 받았을 때 부른다.
47
+ * @returns 새로고침을 걸었으면 true. 이미 최근에 걸었으면 false(진짜 오류로 넘긴다).
48
+ * @public
49
+ */
50
+ export function recoverFromSkew(now = Date.now()) {
51
+ if (now - 마지막시각() <= COOLDOWN_MS)
52
+ return false;
53
+ try {
54
+ sessionStorage.setItem(SKEW_KEY, String(now));
55
+ }
56
+ catch { /* 못 써도 새로고침은 한다 */ }
57
+ window.location.reload();
58
+ return true;
59
+ }
60
+ /**
61
+ * 새로고침을 걸어 두었고 아직 새 판이 안 떴는가.
62
+ * @public
63
+ *
64
+ * `location.reload()` 가 비동기라 그 사이에 React 가 계속 도는데, 그동안 화면에
65
+ * 무엇을 그릴지 정하는 데 쓴다 — **아무것도 안 그리는 것이 맞다.** 곧 판이 바뀐다.
66
+ */
67
+ export function skewReloadPending(now = Date.now()) {
68
+ const last = 마지막시각();
69
+ return last > 0 && now - last <= COOLDOWN_MS;
70
+ }
@@ -3,6 +3,26 @@
3
3
  * 입력에 대시가 있든 없든, 어떤 포맷이든 처리.
4
4
  */
5
5
  export declare function formatPhone(raw: string): string;
6
+ /**
7
+ * 연락처가 **`formatPhone` 이 끝까지 채워 낸 모양** 인가.
8
+ *
9
+ * 만드는 쪽(`formatPhone`)은 코어인데 받는 쪽(형식 검사)은 소비자마다 한 벌씩 두고 있었다 —
10
+ * 말귀 회원앱·어드민에 같은 정규식이 한 벌씩 있었고, 둘이 같은지는 짝 시험이 지켰지만
11
+ * **코어가 만든 모양과 맞는지는 아무도 안 쟀다.** 실제로 코어가 050X·0303(앞자리 네 자리)을
12
+ * 받았을 때 그 두 벌도 손으로 함께 고쳐야 했다. 그래서 짝을 여기 둔다 — 아래 시험이
13
+ * "formatPhone 이 끝까지 채운 값은 늘 통과한다" 를 못 박는다.
14
+ *
15
+ * `formatPhone` 은 **덜 채운 입력을 그대로 돌려준다**(`010-12`). 그래서 빈값 검사만으로는
16
+ * 모자라고, 이 함수가 그 자리를 맡는다.
17
+ *
18
+ * 받는 꼴 — 휴대폰·지역번호(02·0NN)·050X 안심번호·0303 착신전환·**1588 등 대표번호**.
19
+ * 대표번호는 `formatPhone` 이 `1588-1234` 로 만들어 내므로 여기서도 받는다
20
+ * (0 으로 시작하는 것만 받던 소비자 정규식은 그 번호를 거절했다 — 코어가 만든 모양을 코어가 거절하는 꼴).
21
+ * @public
22
+ */
23
+ export declare const PHONE_REGEX: RegExp;
24
+ /** 연락처가 `formatPhone` 이 끝까지 채워 낸 모양인가 — 규칙은 `PHONE_REGEX`. @public */
25
+ export declare function isValidPhone(phone: string): boolean;
6
26
  /**
7
27
  * 날짜를 앱 표준 형식 YYYY.MM.DD 로 정규화.
8
28
  * 지원 입력: YYYYMMDD / YYYY-MM-DD / YYYY.MM.DD / ISO datetime 문자열
@@ -46,6 +46,28 @@ export function formatPhone(raw) {
46
46
  // 끝 4자리를 먼저 고정하고 중간 그룹을 나머지 길이로 계산한다.
47
47
  return `${d.slice(0, 3)}-${d.slice(3, d.length - 4)}-${d.slice(d.length - 4)}`;
48
48
  }
49
+ /**
50
+ * 연락처가 **`formatPhone` 이 끝까지 채워 낸 모양** 인가.
51
+ *
52
+ * 만드는 쪽(`formatPhone`)은 코어인데 받는 쪽(형식 검사)은 소비자마다 한 벌씩 두고 있었다 —
53
+ * 말귀 회원앱·어드민에 같은 정규식이 한 벌씩 있었고, 둘이 같은지는 짝 시험이 지켰지만
54
+ * **코어가 만든 모양과 맞는지는 아무도 안 쟀다.** 실제로 코어가 050X·0303(앞자리 네 자리)을
55
+ * 받았을 때 그 두 벌도 손으로 함께 고쳐야 했다. 그래서 짝을 여기 둔다 — 아래 시험이
56
+ * "formatPhone 이 끝까지 채운 값은 늘 통과한다" 를 못 박는다.
57
+ *
58
+ * `formatPhone` 은 **덜 채운 입력을 그대로 돌려준다**(`010-12`). 그래서 빈값 검사만으로는
59
+ * 모자라고, 이 함수가 그 자리를 맡는다.
60
+ *
61
+ * 받는 꼴 — 휴대폰·지역번호(02·0NN)·050X 안심번호·0303 착신전환·**1588 등 대표번호**.
62
+ * 대표번호는 `formatPhone` 이 `1588-1234` 로 만들어 내므로 여기서도 받는다
63
+ * (0 으로 시작하는 것만 받던 소비자 정규식은 그 번호를 거절했다 — 코어가 만든 모양을 코어가 거절하는 꼴).
64
+ * @public
65
+ */
66
+ export const PHONE_REGEX = /^(?:(?:0\d{1,2}|050\d|0303)-\d{3,4}-\d{4}|1[5-9]\d{2}-\d{4})$/;
67
+ /** 연락처가 `formatPhone` 이 끝까지 채워 낸 모양인가 — 규칙은 `PHONE_REGEX`. @public */
68
+ export function isValidPhone(phone) {
69
+ return PHONE_REGEX.test(phone);
70
+ }
49
71
  /**
50
72
  * 날짜를 앱 표준 형식 YYYY.MM.DD 로 정규화.
51
73
  * 지원 입력: YYYYMMDD / YYYY-MM-DD / YYYY.MM.DD / ISO datetime 문자열
@@ -0,0 +1,23 @@
1
+ import { type ComponentType, type LazyExoticComponent } from 'react';
2
+ /**
3
+ * 화면을 늦게 받아 오는 자리 — `lazy()` 를 그냥 쓰지 않고 이걸 쓴다.
4
+ *
5
+ * 맨 `lazy(() => import(…))` 는 **모듈이 undefined 로 와도 그대로 `.default` 를 읽는다.**
6
+ * 그 일이 실제로 난다: 배포 skew 복구가 `vite:preloadError` 를 `preventDefault()` 하면
7
+ * Vite 헬퍼의 `catch` 가 아무것도 안 던지고 undefined 로 resolve 한다(자세한 것은
8
+ * `chunkSkew.ts`). 그러면 새로고침이 이미 걸려 있는데도 그 틈에 React 가
9
+ * `undefined.default` 를 읽어 **오류 화면이 한 번 뜨고 가짜 보고가 남는다.**
10
+ *
11
+ * 두 가지를 한다.
12
+ * 1. **새로고침이 예약돼 있으면 아무것도 결정하지 않는다** — 영영 resolve 하지 않는
13
+ * 약속을 돌려 Suspense 를 그대로 둔다. 곧 새 판이 뜬다.
14
+ * 2. 그게 아닌데 모듈이 이상하면 **어느 화면인지 적어** 던진다. 지금까지 이 오류는
15
+ * 스택이 통째로 vendor-react 안이라(minify 된 `q`) 어느 화면에서 났는지 알 수 없었다.
16
+ *
17
+ * Vite 가 아닌 번들러(Next 등)에서는 `vite:preloadError` 가 오지 않으니 이 감싸개는
18
+ * "모듈이 이상하면 어느 화면인지 적어 던진다" 만 한다 — 그것만으로도 맨 `lazy` 보다 낫다.
19
+ * @public
20
+ */
21
+ export declare function lazyPage<T extends ComponentType<any>>(name: string, load: () => Promise<{
22
+ default: T;
23
+ } | undefined>): LazyExoticComponent<T>;
@@ -0,0 +1,45 @@
1
+ import { lazy } from 'react';
2
+ import { skewReloadPending } from './chunkSkew';
3
+ /**
4
+ * 화면을 늦게 받아 오는 자리 — `lazy()` 를 그냥 쓰지 않고 이걸 쓴다.
5
+ *
6
+ * 맨 `lazy(() => import(…))` 는 **모듈이 undefined 로 와도 그대로 `.default` 를 읽는다.**
7
+ * 그 일이 실제로 난다: 배포 skew 복구가 `vite:preloadError` 를 `preventDefault()` 하면
8
+ * Vite 헬퍼의 `catch` 가 아무것도 안 던지고 undefined 로 resolve 한다(자세한 것은
9
+ * `chunkSkew.ts`). 그러면 새로고침이 이미 걸려 있는데도 그 틈에 React 가
10
+ * `undefined.default` 를 읽어 **오류 화면이 한 번 뜨고 가짜 보고가 남는다.**
11
+ *
12
+ * 두 가지를 한다.
13
+ * 1. **새로고침이 예약돼 있으면 아무것도 결정하지 않는다** — 영영 resolve 하지 않는
14
+ * 약속을 돌려 Suspense 를 그대로 둔다. 곧 새 판이 뜬다.
15
+ * 2. 그게 아닌데 모듈이 이상하면 **어느 화면인지 적어** 던진다. 지금까지 이 오류는
16
+ * 스택이 통째로 vendor-react 안이라(minify 된 `q`) 어느 화면에서 났는지 알 수 없었다.
17
+ *
18
+ * Vite 가 아닌 번들러(Next 등)에서는 `vite:preloadError` 가 오지 않으니 이 감싸개는
19
+ * "모듈이 이상하면 어느 화면인지 적어 던진다" 만 한다 — 그것만으로도 맨 `lazy` 보다 낫다.
20
+ * @public
21
+ */
22
+ /* 서명은 React 의 `lazy` 를 그대로 따른다 — 화면마다 props 가 달라서, 여기서 좁히면
23
+ 쓰는 쪽이 전부 `Property 'userId' does not exist` 로 막힌다(실제로 그렇게 됐다).
24
+ React 자신도 같은 이유로 이 자리에 any 를 쓴다(@types/react 의 lazy 서명). */
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ export function lazyPage(name,
27
+ /* 반환 타입에 undefined 를 적어 둔다 — 실제로 그렇게 온다(아래 참고). 타입이
28
+ "그럴 리 없다"고 말하면 부르는 쪽이 `.then(m => ({ default: m.X }))` 처럼 모듈을
29
+ 그대로 읽는 코드를 쓰게 되고, 그러면 여기 닿기도 전에 터진다. */
30
+ load) {
31
+ return lazy(async () => {
32
+ /* **undefined 로 resolve 될 수 있다.** 타입은 그럴 리 없다고 하지만 실제로 그렇게 온다
33
+ (배포 skew 복구가 preventDefault 한 뒤). 타입을 믿지 말고 확인한다. */
34
+ const mod = await load();
35
+ if (mod && typeof mod === 'object' && 'default' in mod) {
36
+ return mod;
37
+ }
38
+ /* 여기 오는 길은 사실상 하나뿐이다 — 청크를 못 받아 복구가 도는 중. 새 판을
39
+ 기다린다. 영영 resolve 하지 않는 약속을 돌려 React 가 이 경계를 건드리지 않게 한다. */
40
+ if (skewReloadPending())
41
+ return new Promise(() => { });
42
+ throw new Error(`[lazyPage] ${name}: 화면 모듈을 받지 못했습니다(default 내보내기 없음). ` +
43
+ '배포 직후라면 새로고침으로 풀립니다.');
44
+ });
45
+ }
@@ -36,7 +36,7 @@ export { createTTSUrlResolver } from './utils/ttsResolver';
36
36
  export type { CreateTTSResolverOptions } from './utils/ttsResolver';
37
37
  export { toStorageName, toBase64UrlSafe, officialSoundPath } from './utils/encoding';
38
38
  export { copyText } from './utils/clipboard';
39
- export { formatPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
39
+ export { formatPhone, PHONE_REGEX, isValidPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
40
40
  export { configureTrainingCategories, getTopicTypes, getTrainingTypes } from './utils/categories';
41
41
  export type { TopicType, TopicSubType, TrainingType, TrainingCategoryConfig } from './utils/categories';
42
42
  export { formatYmd, formatRelativeTime, getTodayKst, getTodayKstDate, kstStartOfDay, parseLocalYmd, toKstYmd, toKstHm, formatDateKo, formatDateLongKo, formatDatetimeKo, formatDateLocale, formatDateLongLocale } from './utils/date';
@@ -55,4 +55,6 @@ export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wor
55
55
  export { isSyllablePattern, matchesSyllablePattern, validateSyllableSearchInput, patternToComposition, matchesWordQuery, searchAndRankWords } from './utils/koreanSyllable';
56
56
  export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, isManagerTier, type PlanTier, type DataLevel, type PlanFeatures, type PlanLimits, } from './utils/planFeatures';
57
57
  export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGESTION_EXISTING_ERROR, isThisWeekPick, } from './utils/wordSuggestionPolicy';
58
+ export { recoverFromSkew, skewReloadPending } from './utils/chunkSkew';
59
+ export { lazyPage } from './utils/lazyPage';
58
60
  export { CORE_VERSION } from './version';
@@ -25,7 +25,7 @@ export { configureNoiseSettingsStore, loadNoiseSettings, saveNoiseSettings, repo
25
25
  export { createTTSUrlResolver } from './utils/ttsResolver';
26
26
  export { toStorageName, toBase64UrlSafe, officialSoundPath } from './utils/encoding';
27
27
  export { copyText } from './utils/clipboard';
28
- export { formatPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
28
+ export { formatPhone, PHONE_REGEX, isValidPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
29
29
  export { configureTrainingCategories, getTopicTypes, getTrainingTypes } from './utils/categories';
30
30
  export { formatYmd, formatRelativeTime, getTodayKst, getTodayKstDate, kstStartOfDay, parseLocalYmd, toKstYmd, toKstHm, formatDateKo, formatDateLongKo, formatDatetimeKo, formatDateLocale, formatDateLongLocale } from './utils/date';
31
31
  export { getSavedTheme, applyTheme, initTheme } from './utils/theme';
@@ -44,4 +44,6 @@ export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, isManagerTier, } fr
44
44
  말귀어드민(단어 제안 관리)이 같은 숫자를 봐야 하는데, 한쪽 화면 폴더에 두면
45
45
  다른 쪽이 그 폴더 없이는 빌드되지 않는다. 값과 순수 함수뿐이다. */
46
46
  export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGESTION_EXISTING_ERROR, isThisWeekPick, } from './utils/wordSuggestionPolicy';
47
+ export { recoverFromSkew, skewReloadPending } from './utils/chunkSkew';
48
+ export { lazyPage } from './utils/lazyPage';
47
49
  export { CORE_VERSION } from './version';
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.170.0";
49
+ export declare const CORE_VERSION = "0.172.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.170.0';
49
+ export const CORE_VERSION = '0.172.0';