@iyulab/router 0.11.4 → 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 CHANGED
@@ -1,5 +1,56 @@
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
+
41
+ ## [0.11.5] - 2026-09-10
42
+
43
+ ### Fixed
44
+
45
+ - **`u-error-page`'s three colour hooks were documented as having no default.** They were
46
+ described as falling back to the inherited colour, when the host block in fact declares a
47
+ concrete colour for each — `--error-icon-color` `#4a5568` (dark `#a0aec0`),
48
+ `--error-code-color` `#1a202c` (dark `#f7fafc`), `--error-message-color` `#718096`
49
+ (dark `#cbd5e0`). A consumer reading the old text would expect the page to pick up the
50
+ surrounding colour and find that it does not. The documentation now also states the
51
+ consequence those literals carry: this page does not follow the host application's design
52
+ tokens, and replacing it wholesale is what `fallback.render` is for. No behaviour changed.
53
+
3
54
  ## [0.11.4] - 2026-09-01
4
55
 
5
56
  ### Fixed
@@ -9,9 +9,13 @@ import { TemplateResult } from 'lit-html';
9
9
  * 아니다. 다만 라우팅 실패 시 실제 DOM에 렌더되므로, 기본 화면의 색만 가볍게
10
10
  * 맞추고 싶은 소비자를 위해 세 색상 훅을 남겨 둔다(전체 교체는 `fallback.render`).
11
11
  *
12
- * @cssprop --error-icon-color - 아이콘 색. 기본값 없음(미지정 시 상속된 색 사용)
13
- * @cssprop --error-code-color - 에러 코드 텍스트 색. 기본값 없음(미지정 시 상속된 색 사용)
14
- * @cssprop --error-message-color - 에러 메시지 텍스트 색. 기본값 없음(미지정 시 상속된 색 사용)
12
+ * @cssprop --error-icon-color - 아이콘 색. 기본값 `#4a5568`(다크 `#a0aec0`)
13
+ * @cssprop --error-code-color - 에러 코드 텍스트 색. 기본값 `#1a202c`(다크 `#f7fafc`)
14
+ * @cssprop --error-message-color - 에러 메시지 텍스트 색. 기본값 `#718096`(다크 `#cbd5e0`)
15
+ *
16
+ * ⚠세 값은 아래 `static styles` 의 `:host` 에서 **구체적인 색으로 선언된다** — 지정하지
17
+ * 않으면 주변 색을 상속하는 것이 아니라 위 기본값이 적용된다. 리터럴이므로 이 페이지는
18
+ * 호스트 앱의 디자인 토큰을 따르지 않는다.
15
19
  */
16
20
  export declare class UErrorPage extends LitElement {
17
21
  constructor(error?: RouteError);
@@ -1,9 +1,12 @@
1
1
  /** 렌더링 옵션 */
2
2
  interface RenderOption {
3
- /** 교차 렌더링 방지 ID */
3
+ /** 교차 렌더링 방지 ID — 어느 라우트의 콘텐츠인가 */
4
4
  id?: string;
5
- /** 강제 렌더링 여부 */
6
- force?: boolean;
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-BOhUKwJL.js";
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.force ||= true;
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.force ||= false;
325
- } else route.force ||= true;
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
- force: route.force
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
@@ -1,4 +1,4 @@
1
- import { s as UOutlet$1, t as ULink$1 } from "./share-BOhUKwJL.js";
1
+ import { s as UOutlet$1, t as ULink$1 } from "./share-ByPkeTq1.js";
2
2
  import React from "react";
3
3
  import { createComponent } from "@lit/react";
4
4
  //#region src/react.ts
@@ -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
- if (this.routeId === options?.id && options?.force === false) return;
14
- this.routeId = options?.id;
15
- this.reset();
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
- if (value instanceof HTMLElement) {
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 ("_$litType$" in value) this.root = render(value, this);
22
- else if ("$$typeof" in value) {
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
@@ -1026,12 +1059,12 @@ function catchBasepath(basepath) {
1026
1059
  return basepath;
1027
1060
  }
1028
1061
  //#endregion
1029
- //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorateMetadata.js
1062
+ //#region \0@oxc-project+runtime@0.149.0/helpers/esm/decorateMetadata.js
1030
1063
  function __decorateMetadata(k, v) {
1031
1064
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
1032
1065
  }
1033
1066
  //#endregion
1034
- //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorate.js
1067
+ //#region \0@oxc-project+runtime@0.149.0/helpers/esm/decorate.js
1035
1068
  function __decorate(decorators, target, key, desc) {
1036
1069
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
1037
1070
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -67,9 +67,34 @@ interface BaseRouteConfig {
67
67
  */
68
68
  enter?: (ctx: RouteContext) => Promise<string | boolean> | string | boolean;
69
69
  /**
70
- * 라우터 URL 변경시 렌더링을 강제할지 여부
71
- * - 기본값으로 children을 가질때 false로 설정되며, children이 없을 경우 true로 설정됩니다.
72
- * - true로 설정하면 기존 렌더링을 무시하고 새로 렌더링합니다.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/router",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "description": "A modern client-side router for web applications with support for Lit and React components",
5
5
  "keywords": [
6
6
  "lit",
@@ -75,7 +75,7 @@
75
75
  "@types/react": "^19.2.17",
76
76
  "@types/react-dom": "^19.2.3",
77
77
  "happy-dom": "^20.10.6",
78
- "typescript": "^5.9.3",
78
+ "typescript": "^6.0.2",
79
79
  "vite": "^8.1.4",
80
80
  "vite-plugin-dts": "^5.0.3",
81
81
  "vitest": "^4.1.10"
@@ -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
- | `force` | `boolean` | Force re-render on URL change (default `true` for leaf routes) |
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
 
@@ -49,11 +49,16 @@ A parent route must render `<u-outlet>` to host child route content.
49
49
  custom properties are available to lightly restyle it without replacing
50
50
  `fallback.render` entirely:
51
51
 
52
- | Custom Property | Description | Default |
53
- | --- | --- | --- |
54
- | `--error-icon-color` | Icon color | none inherits the ambient text color |
55
- | `--error-code-color` | Error code text color | none inherits the ambient text color |
56
- | `--error-message-color` | Error message text color | none inherits the ambient text color |
52
+ | Custom Property | Description | Default (light) | Default (dark) |
53
+ | --- | --- | --- | --- |
54
+ | `--error-icon-color` | Icon color | `#4a5568` | `#a0aec0` |
55
+ | `--error-code-color` | Error code text color | `#1a202c` | `#f7fafc` |
56
+ | `--error-message-color` | Error message text color | `#718096` | `#cbd5e0` |
57
+
58
+ The defaults are concrete colors set on `:host`, not inherited — the dark column applies
59
+ under `prefers-color-scheme: dark`. Because they are literals rather than design tokens,
60
+ this page does not follow a host application's theme; set the three properties (or supply
61
+ your own `fallback.render`) if it has to.
57
62
 
58
63
  ```css
59
64
  u-error-page {