@iyulab/router 0.11.5 → 0.12.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.
- package/CHANGELOG.md +38 -0
- package/dist/components/UOutlet.d.ts +19 -3
- package/dist/index.js +18 -9
- package/dist/react.js +1 -1
- package/dist/{share-DyVUteH9.js → share-ByPkeTq1.js} +42 -9
- package/dist/types/RouteConfig.d.ts +28 -3
- package/package.json +1 -1
- package/skills/iyulab-router/SKILL.md +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.0] - 2026-09-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`RouteConfig.key` — the route decides when its content is remounted.** Each navigation
|
|
8
|
+
computes `key(ctx)`; while it is unchanged the outlet keeps the mounted content and re-renders
|
|
9
|
+
it in place with the new `ctx` — a Lit template is rendered into the same part (elements and
|
|
10
|
+
their state survive, only bindings change), a React element into the same root (component
|
|
11
|
+
state survives, props change), and an `HTMLElement` is left as it is. A changed key unmounts
|
|
12
|
+
and mounts fresh, as every navigation did before. Click interception, `go()` and `popstate`
|
|
13
|
+
all follow the one rule because it lives on the route, not on the call. The default is
|
|
14
|
+
`ctx => ctx.href` for leaf routes, so nothing changes until a route opts in:
|
|
15
|
+
`{ path: '/orders', key: ctx => ctx.pathname, render: ctx => html`<orders-page .selectedId=${ctx.query.get('id')}></orders-page>` }`
|
|
16
|
+
keeps the list — its scroll, selection and loaded rows — while `?id=` opens and closes a
|
|
17
|
+
detail overlay.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **A layout route (one with `children`) is now re-rendered in place when a child changes.** It
|
|
22
|
+
was already kept across child navigations, but its `render(ctx)` result was thrown away, so a
|
|
23
|
+
layout could never react to the current `ctx` (an active-menu highlight, a breadcrumb). It now
|
|
24
|
+
receives the new `ctx` through the same in-place path; its inner `<u-outlet>` and the child
|
|
25
|
+
content are untouched.
|
|
26
|
+
- **Outlet renders are serialised.** A render that arrives while a React mount is still awaiting
|
|
27
|
+
its dynamic import now waits for that mount instead of racing it.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **`force: false` on a leaf route was silently ignored.** Defaults were applied with
|
|
32
|
+
`route.force ||= true`, which turns an explicit `false` into `true` — the documented option
|
|
33
|
+
could not be set on the routes it was documented for. `force` is now deprecated in favour of
|
|
34
|
+
`key` (`false` ≡ a constant key, `true` ≡ the default) and, for this release, honoured as
|
|
35
|
+
written on every route.
|
|
36
|
+
|
|
37
|
+
### Deprecated
|
|
38
|
+
|
|
39
|
+
- `RouteConfig.force` — use `key`. Still honoured in this release; removed in the next minor.
|
|
40
|
+
|
|
3
41
|
## [0.11.5] - 2026-09-10
|
|
4
42
|
|
|
5
43
|
### Fixed
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/** 렌더링 옵션 */
|
|
2
2
|
interface RenderOption {
|
|
3
|
-
/** 교차 렌더링 방지 ID */
|
|
3
|
+
/** 교차 렌더링 방지 ID — 어느 라우트의 콘텐츠인가 */
|
|
4
4
|
id?: string;
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* 라우트의 식별 키(`RouteConfig.key` 의 결과). 같은 라우트 + 같은 키면 기존 콘텐츠를
|
|
7
|
+
* 유지한 채 제자리 갱신하고, 바뀌면 내리고 새로 마운트한다.
|
|
8
|
+
*/
|
|
9
|
+
key?: string;
|
|
7
10
|
}
|
|
8
11
|
/**
|
|
9
12
|
* LitElement 또는 React 컴포넌트를 렌더링해주는 웹컴포넌트 입니다.
|
|
@@ -11,12 +14,25 @@ interface RenderOption {
|
|
|
11
14
|
declare class UOutlet extends HTMLElement {
|
|
12
15
|
/** 교차 렌더링 방지 id */
|
|
13
16
|
private routeId?;
|
|
17
|
+
/** 마지막으로 렌더한 라우트의 식별 키 */
|
|
18
|
+
private routeKey?;
|
|
19
|
+
/** 마운트된 콘텐츠의 종류 */
|
|
20
|
+
private kind?;
|
|
14
21
|
/** 실제 렌더링 컨텐츠 */
|
|
15
22
|
private root?;
|
|
23
|
+
/** 진행 중인 render — 다음 render 는 이것이 끝난 뒤 판정한다 */
|
|
24
|
+
private pending?;
|
|
16
25
|
/**
|
|
17
26
|
* 주어진 렌더링 옵션에 따라 컨텐츠를 렌더링합니다.
|
|
27
|
+
*
|
|
28
|
+
* 같은 라우트(`id`)에 같은 키(`key`)로 다시 불리면 **제자리 갱신**한다 — Lit 템플릿은
|
|
29
|
+
* 같은 파트에 다시 렌더(요소·상태 유지, 바인딩만 갱신), React 엘리먼트는 같은 root 에 다시
|
|
30
|
+
* 렌더(컴포넌트 상태 유지), `HTMLElement` 는 기존 인스턴스를 그대로 둔다. 매번 `reset()`
|
|
31
|
+
* 을 먼저 부르던 종전 동작이 «쿼리스트링만 바뀌어도 페이지가 재마운트되는» 원인이었다
|
|
32
|
+
* (Lit 의 `render` 도 React 의 `root.render` 도 같은 컨테이너에 다시 부르면 조정한다).
|
|
18
33
|
*/
|
|
19
34
|
render(value: unknown, options?: RenderOption): Promise<void>;
|
|
35
|
+
private mount;
|
|
20
36
|
/**
|
|
21
37
|
* 기존 DOM을 삭제하여, 초기 상태로 되돌립니다.
|
|
22
38
|
*/
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as isExternalUrl, i as absolutePath, n as __decorate, o as parseUrl, r as __decorateMetadata, s as UOutlet, t as ULink } from "./share-
|
|
1
|
+
import { a as isExternalUrl, i as absolutePath, n as __decorate, o as parseUrl, r as __decorateMetadata, s as UOutlet, t as ULink } from "./share-ByPkeTq1.js";
|
|
2
2
|
import { LitElement, css, html } from "lit";
|
|
3
3
|
import { customElement, property } from "lit/decorators.js";
|
|
4
4
|
//#region src/types/RouteError.ts
|
|
@@ -299,6 +299,18 @@ function findAnchorFrom(event) {
|
|
|
299
299
|
*/
|
|
300
300
|
var createURLPattern = URLPattern;
|
|
301
301
|
/**
|
|
302
|
+
* `key` 가 없을 때의 기본 식별 키 — «언제 새로 만드는가» 의 기본 규칙.
|
|
303
|
+
*
|
|
304
|
+
* 자식이 없는 라우트는 URL(`href`) 이 조금이라도 바뀌면 새로 마운트하고, 자식을 가진 라우트
|
|
305
|
+
* (레이아웃)는 상수 키로 유지된다 — 종전의 `force` 기본값(leaf true · parent false)과 같은
|
|
306
|
+
* 결과다. 다만 종전 코드는 `route.force ||= true` 라서 소비자가 leaf 에 `force: false` 를
|
|
307
|
+
* 명시해도 `true` 로 덮였다(`false || true`). `force` 는 deprecate 됐지만 이 판에서는
|
|
308
|
+
* 존중한다: `false` 는 «유지», `true` 는 «새로».
|
|
309
|
+
*/
|
|
310
|
+
function defaultKey(route, hasChildren) {
|
|
311
|
+
return route.force === false || hasChildren && route.force !== true ? () => "" : (ctx) => ctx.href;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
302
314
|
* 라우트들을 다음 사항에 따라 재귀적으로 재설정합니다.
|
|
303
315
|
* - 각 라우트에 고유 `id`를 랜덤하게 부여합니다.
|
|
304
316
|
* - `path`를 URLPattern 객체로 변환합니다.
|
|
@@ -314,15 +326,15 @@ function setRoutes(routes, basepath) {
|
|
|
314
326
|
route.ignoreCase ||= false;
|
|
315
327
|
if (route.index === true) {
|
|
316
328
|
route.path = new createURLPattern({ pathname: `${basepath}{/}?` }, { ignoreCase: route.ignoreCase });
|
|
317
|
-
route.
|
|
329
|
+
route.key ??= defaultKey(route, false);
|
|
318
330
|
} else {
|
|
319
331
|
if (typeof route.path === "string") route.path = new createURLPattern({ pathname: `${absolutePath(basepath, route.path)}{/}?` }, { ignoreCase: route.ignoreCase });
|
|
320
332
|
else if (route.path instanceof URLPattern) {} else route.path = new createURLPattern({ pathname: `${basepath}{/}?` }, { ignoreCase: route.ignoreCase });
|
|
321
333
|
if (route.children && route.children.length > 0) {
|
|
322
334
|
const childBasepath = route.path.pathname.replace("{/}?", "");
|
|
323
335
|
route.children = setRoutes(route.children, childBasepath);
|
|
324
|
-
route.
|
|
325
|
-
} else route.
|
|
336
|
+
route.key ??= defaultKey(route, true);
|
|
337
|
+
} else route.key ??= defaultKey(route, false);
|
|
326
338
|
}
|
|
327
339
|
}
|
|
328
340
|
return routes;
|
|
@@ -517,7 +529,7 @@ var Router = class {
|
|
|
517
529
|
try {
|
|
518
530
|
await outlet.render(content, {
|
|
519
531
|
id: route.id,
|
|
520
|
-
|
|
532
|
+
key: route.key?.(context)
|
|
521
533
|
});
|
|
522
534
|
} catch (e) {
|
|
523
535
|
throw new ContentRenderError(e);
|
|
@@ -536,10 +548,7 @@ var Router = class {
|
|
|
536
548
|
...context,
|
|
537
549
|
error: routeError
|
|
538
550
|
}) : new UErrorPage(routeError);
|
|
539
|
-
if (outlet) outlet.render(content, {
|
|
540
|
-
id: getRandomID(),
|
|
541
|
-
force: true
|
|
542
|
-
});
|
|
551
|
+
if (outlet) outlet.render(content, { id: getRandomID() });
|
|
543
552
|
else {
|
|
544
553
|
document.body.innerHTML = "";
|
|
545
554
|
document.body.appendChild(content instanceof Node ? content : new UErrorPage(routeError));
|
package/dist/react.js
CHANGED
|
@@ -8,24 +8,48 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
|
|
8
8
|
var UOutlet = class extends HTMLElement {
|
|
9
9
|
/**
|
|
10
10
|
* 주어진 렌더링 옵션에 따라 컨텐츠를 렌더링합니다.
|
|
11
|
+
*
|
|
12
|
+
* 같은 라우트(`id`)에 같은 키(`key`)로 다시 불리면 **제자리 갱신**한다 — Lit 템플릿은
|
|
13
|
+
* 같은 파트에 다시 렌더(요소·상태 유지, 바인딩만 갱신), React 엘리먼트는 같은 root 에 다시
|
|
14
|
+
* 렌더(컴포넌트 상태 유지), `HTMLElement` 는 기존 인스턴스를 그대로 둔다. 매번 `reset()`
|
|
15
|
+
* 을 먼저 부르던 종전 동작이 «쿼리스트링만 바뀌어도 페이지가 재마운트되는» 원인이었다
|
|
16
|
+
* (Lit 의 `render` 도 React 의 `root.render` 도 같은 컨테이너에 다시 부르면 조정한다).
|
|
11
17
|
*/
|
|
12
18
|
async render(value, options) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
const prev = this.pending;
|
|
20
|
+
const run = (async () => {
|
|
21
|
+
if (prev) await prev.catch(() => void 0);
|
|
22
|
+
await this.mount(value, options);
|
|
23
|
+
})();
|
|
24
|
+
this.pending = run;
|
|
25
|
+
try {
|
|
26
|
+
await run;
|
|
27
|
+
} finally {
|
|
28
|
+
if (this.pending === run) this.pending = void 0;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async mount(value, options) {
|
|
16
32
|
if (value === null) throw new Error("Content is null and cannot be rendered.");
|
|
17
33
|
if (typeof value !== "object") throw new Error("Content is not a valid renderable object.");
|
|
18
|
-
|
|
34
|
+
const kind = contentKind(value);
|
|
35
|
+
const inPlace = this.kind !== void 0 && this.kind === kind && this.routeId === options?.id && this.routeKey === options?.key;
|
|
36
|
+
this.routeId = options?.id;
|
|
37
|
+
this.routeKey = options?.key;
|
|
38
|
+
if (inPlace) {
|
|
39
|
+
if (kind === "lit") this.root = render(value, this);
|
|
40
|
+
else if (kind === "react") this.root.render(value);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.reset();
|
|
44
|
+
this.kind = kind;
|
|
45
|
+
if (kind === "element") {
|
|
19
46
|
this.replaceChildren(value);
|
|
20
47
|
this.root = void 0;
|
|
21
|
-
} else if ("
|
|
22
|
-
else
|
|
48
|
+
} else if (kind === "lit") this.root = render(value, this);
|
|
49
|
+
else {
|
|
23
50
|
const { createRoot } = await import("react-dom/client");
|
|
24
51
|
this.root = createRoot(this);
|
|
25
52
|
this.root.render(value);
|
|
26
|
-
} else {
|
|
27
|
-
const receivedType = value?.constructor?.name ?? typeof value;
|
|
28
|
-
throw new Error(`Unsupported content type for Outlet rendering: received ${receivedType}. Expected an HTMLElement, a Lit TemplateResult, or a React element.`);
|
|
29
53
|
}
|
|
30
54
|
}
|
|
31
55
|
/**
|
|
@@ -35,9 +59,18 @@ var UOutlet = class extends HTMLElement {
|
|
|
35
59
|
if (this.root && "_$litPart$" in this) delete this._$litPart$;
|
|
36
60
|
if (this.root && "unmount" in this.root) this.root.unmount();
|
|
37
61
|
this.root = void 0;
|
|
62
|
+
this.kind = void 0;
|
|
38
63
|
this.innerHTML = "";
|
|
39
64
|
}
|
|
40
65
|
};
|
|
66
|
+
/** 렌더 가능한 세 종류 중 무엇인가 — 아니면 서술적으로 던진다. */
|
|
67
|
+
function contentKind(value) {
|
|
68
|
+
if (value instanceof HTMLElement) return "element";
|
|
69
|
+
if ("_$litType$" in value) return "lit";
|
|
70
|
+
if ("$$typeof" in value) return "react";
|
|
71
|
+
const receivedType = value?.constructor?.name ?? typeof value;
|
|
72
|
+
throw new Error(`Unsupported content type for Outlet rendering: received ${receivedType}. Expected an HTMLElement, a Lit TemplateResult, or a React element.`);
|
|
73
|
+
}
|
|
41
74
|
customElements.define("u-outlet", UOutlet);
|
|
42
75
|
//#endregion
|
|
43
76
|
//#region node_modules/urlpattern-polyfill/dist/urlpattern.js
|
|
@@ -67,9 +67,34 @@ interface BaseRouteConfig {
|
|
|
67
67
|
*/
|
|
68
68
|
enter?: (ctx: RouteContext) => Promise<string | boolean> | string | boolean;
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* 이 라우트의 콘텐츠를 **언제 새로 만들 것인가**를 정하는 식별 키입니다.
|
|
71
|
+
*
|
|
72
|
+
* 네비게이션마다 `key(ctx)`를 계산해 직전 값과 비교합니다.
|
|
73
|
+
* - 키가 **바뀌면** 기존 콘텐츠를 내리고 새로 마운트합니다.
|
|
74
|
+
* - 키가 **같으면** 콘텐츠를 유지한 채 `render(ctx)`의 결과로 **제자리 갱신**합니다 —
|
|
75
|
+
* Lit 템플릿은 같은 파트에 다시 렌더(DOM·요소 상태 유지, 바인딩만 갱신), React 엘리먼트는
|
|
76
|
+
* 같은 root에 다시 렌더(컴포넌트 상태 유지, props만 갱신), `HTMLElement`는 기존 인스턴스를
|
|
77
|
+
* 그대로 둡니다(조정할 수단이 없습니다). 새 `ctx`는 따로 전달되지 않고 `render(ctx)`를
|
|
78
|
+
* 통해 도달합니다.
|
|
79
|
+
*
|
|
80
|
+
* 기본값: 자식 라우트가 없으면 `ctx => ctx.href`(URL이 조금이라도 바뀌면 새로), 자식 라우트가
|
|
81
|
+
* 있으면 상수(레이아웃은 유지하고 자식만 바뀝니다).
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```typescript
|
|
85
|
+
* // 쿼리스트링만 바뀌면 페이지를 유지하고 prop만 갱신 — 목록 상태·스크롤이 살아남습니다
|
|
86
|
+
* { path: '/orders', key: ctx => ctx.pathname,
|
|
87
|
+
* render: ctx => html`<orders-page .selectedId=${ctx.query.get('id')}></orders-page>` }
|
|
88
|
+
* // params가 바뀌어도 유지
|
|
89
|
+
* { path: '/orders/:id', key: () => 'orders', render: ctx => html`...` }
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
key?: (ctx: RouteContext) => string;
|
|
93
|
+
/**
|
|
94
|
+
* @deprecated `key`로 표현하세요 — `force: false`는 `key: () => ''`(상수 키)와 같고 `force: true`는
|
|
95
|
+
* 기본값과 같습니다. 이 판에서는 동작을 유지하며, 다음 minor에서 제거됩니다.
|
|
96
|
+
* ⚠이전 판에서는 자식이 없는 라우트에 `force: false`를 줘도 무시됐습니다(기본값 적용 순서의 결함).
|
|
97
|
+
* 이제 `force: false`는 모든 라우트에서 «유지»를 뜻합니다.
|
|
73
98
|
*/
|
|
74
99
|
force?: boolean;
|
|
75
100
|
/**
|
package/package.json
CHANGED
|
@@ -71,7 +71,8 @@ const router = new Router({
|
|
|
71
71
|
| `children` | `RouteConfig[]` | Nested routes; parent must render `<u-outlet>` |
|
|
72
72
|
| `title` | `string` | Sets `document.title` on match |
|
|
73
73
|
| `metadata` | `Record<string, unknown>` | Arbitrary metadata (auth, layout, analytics) |
|
|
74
|
-
| `
|
|
74
|
+
| `key` | `(ctx) => string` | When to remount: content is kept and re-rendered in place while the key is unchanged (Lit: same part, React: same root, `HTMLElement`: instance kept). Default `ctx => ctx.href` for leaf routes, a constant for routes with `children`. `key: ctx => ctx.pathname` keeps a page across query-string changes |
|
|
75
|
+
| `force` | `boolean` | **Deprecated** — use `key`. `false` ≡ constant key (keep), `true` ≡ default |
|
|
75
76
|
|
|
76
77
|
## RouteContext Fields
|
|
77
78
|
|