@iyulab/router 0.11.5 → 0.13.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,54 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0] - 2026-09-13
4
+
5
+ ### Removed
6
+
7
+ - **`RouteConfig.force`** — deprecated in 0.12.0, removed as announced. It only ever expressed
8
+ the two values `key` already covers: `force: false` is `key: () => ''` (keep the mounted
9
+ content across every navigation that matches the route), `force: true` is the default
10
+ (`ctx => ctx.href` — remount when the URL changes at all). Replace one with the other; a
11
+ route that never set `force` is unaffected. The property is gone from the type, so a stale
12
+ usage fails at compile time rather than silently doing nothing.
13
+
14
+ ## [0.12.0] - 2026-09-13
15
+
16
+ ### Added
17
+
18
+ - **`RouteConfig.key` — the route decides when its content is remounted.** Each navigation
19
+ computes `key(ctx)`; while it is unchanged the outlet keeps the mounted content and re-renders
20
+ it in place with the new `ctx` — a Lit template is rendered into the same part (elements and
21
+ their state survive, only bindings change), a React element into the same root (component
22
+ state survives, props change), and an `HTMLElement` is left as it is. A changed key unmounts
23
+ and mounts fresh, as every navigation did before. Click interception, `go()` and `popstate`
24
+ all follow the one rule because it lives on the route, not on the call. The default is
25
+ `ctx => ctx.href` for leaf routes, so nothing changes until a route opts in:
26
+ `{ path: '/orders', key: ctx => ctx.pathname, render: ctx => html`<orders-page .selectedId=${ctx.query.get('id')}></orders-page>` }`
27
+ keeps the list — its scroll, selection and loaded rows — while `?id=` opens and closes a
28
+ detail overlay.
29
+
30
+ ### Changed
31
+
32
+ - **A layout route (one with `children`) is now re-rendered in place when a child changes.** It
33
+ was already kept across child navigations, but its `render(ctx)` result was thrown away, so a
34
+ layout could never react to the current `ctx` (an active-menu highlight, a breadcrumb). It now
35
+ receives the new `ctx` through the same in-place path; its inner `<u-outlet>` and the child
36
+ content are untouched.
37
+ - **Outlet renders are serialised.** A render that arrives while a React mount is still awaiting
38
+ its dynamic import now waits for that mount instead of racing it.
39
+
40
+ ### Fixed
41
+
42
+ - **`force: false` on a leaf route was silently ignored.** Defaults were applied with
43
+ `route.force ||= true`, which turns an explicit `false` into `true` — the documented option
44
+ could not be set on the routes it was documented for. `force` is now deprecated in favour of
45
+ `key` (`false` ≡ a constant key, `true` ≡ the default) and, for this release, honoured as
46
+ written on every route.
47
+
48
+ ### Deprecated
49
+
50
+ - `RouteConfig.force` — use `key`. Still honoured in this release; removed in the next minor.
51
+
3
52
  ## [0.11.5] - 2026-09-10
4
53
 
5
54
  ### Fixed
@@ -64,7 +64,7 @@ export declare class ULink extends LitElement {
64
64
  /**
65
65
  * 호스트에 세팅된 `aria-current`/`aria-label`은 실제 접근 가능한(포커스 대상)
66
66
  * 엘리먼트가 아니라 — 그 안쪽 shadow DOM 의 네이티브 `<a>`다. 섀도우 경계를
67
- * 넘지 않으므로 접근성 트리에 자동 반영되지 않는다(docket #45 실측 — 속성은
67
+ * 넘지 않으므로 접근성 트리에 자동 반영되지 않는다(실측 — 속성은
68
68
  * 붙어 있는데 접근성 트리의 `aria-current`는 계속 비어 있음). `render()`가 이
69
69
  * 값을 읽어 내부 `<a>`에 직접 옮긴다.
70
70
  *
@@ -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-DyVUteH9.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-A3Fla8oW.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,17 @@ function findAnchorFrom(event) {
299
299
  */
300
300
  var createURLPattern = URLPattern;
301
301
  /**
302
+ * `key` 가 없을 때의 기본 식별 키 — «언제 새로 만드는가» 의 기본 규칙.
303
+ *
304
+ * 자식이 없는 라우트는 URL(`href`) 이 조금이라도 바뀌면 새로 마운트하고, 자식을 가진 라우트
305
+ * (레이아웃)는 상수 키로 유지된다. 그 밖의 규칙은 소비자가 `key` 로 직접 준다 — 예전의
306
+ * `force` 불리언은 이 두 값(«유지»·«새로»)밖에 표현하지 못해 `key` 로 대체됐고 0.13.0 에서
307
+ * 제거됐다(`force: false` ≡ `key: () => ''` · `force: true` ≡ 기본값).
308
+ */
309
+ function defaultKey(hasChildren) {
310
+ return hasChildren ? () => "" : (ctx) => ctx.href;
311
+ }
312
+ /**
302
313
  * 라우트들을 다음 사항에 따라 재귀적으로 재설정합니다.
303
314
  * - 각 라우트에 고유 `id`를 랜덤하게 부여합니다.
304
315
  * - `path`를 URLPattern 객체로 변환합니다.
@@ -314,15 +325,15 @@ function setRoutes(routes, basepath) {
314
325
  route.ignoreCase ||= false;
315
326
  if (route.index === true) {
316
327
  route.path = new createURLPattern({ pathname: `${basepath}{/}?` }, { ignoreCase: route.ignoreCase });
317
- route.force ||= true;
328
+ route.key ??= defaultKey(false);
318
329
  } else {
319
330
  if (typeof route.path === "string") route.path = new createURLPattern({ pathname: `${absolutePath(basepath, route.path)}{/}?` }, { ignoreCase: route.ignoreCase });
320
331
  else if (route.path instanceof URLPattern) {} else route.path = new createURLPattern({ pathname: `${basepath}{/}?` }, { ignoreCase: route.ignoreCase });
321
332
  if (route.children && route.children.length > 0) {
322
333
  const childBasepath = route.path.pathname.replace("{/}?", "");
323
334
  route.children = setRoutes(route.children, childBasepath);
324
- route.force ||= false;
325
- } else route.force ||= true;
335
+ route.key ??= defaultKey(true);
336
+ } else route.key ??= defaultKey(false);
326
337
  }
327
338
  }
328
339
  return routes;
@@ -517,7 +528,7 @@ var Router = class {
517
528
  try {
518
529
  await outlet.render(content, {
519
530
  id: route.id,
520
- force: route.force
531
+ key: route.key?.(context)
521
532
  });
522
533
  } catch (e) {
523
534
  throw new ContentRenderError(e);
@@ -536,10 +547,7 @@ var Router = class {
536
547
  ...context,
537
548
  error: routeError
538
549
  }) : new UErrorPage(routeError);
539
- if (outlet) outlet.render(content, {
540
- id: getRandomID(),
541
- force: true
542
- });
550
+ if (outlet) outlet.render(content, { id: getRandomID() });
543
551
  else {
544
552
  document.body.innerHTML = "";
545
553
  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-DyVUteH9.js";
1
+ import { s as UOutlet$1, t as ULink$1 } from "./share-A3Fla8oW.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
@@ -1087,7 +1120,7 @@ var ULink = class ULink extends LitElement {
1087
1120
  /**
1088
1121
  * 호스트에 세팅된 `aria-current`/`aria-label`은 실제 접근 가능한(포커스 대상)
1089
1122
  * 엘리먼트가 아니라 — 그 안쪽 shadow DOM 의 네이티브 `<a>`다. 섀도우 경계를
1090
- * 넘지 않으므로 접근성 트리에 자동 반영되지 않는다(docket #45 실측 — 속성은
1123
+ * 넘지 않으므로 접근성 트리에 자동 반영되지 않는다(실측 — 속성은
1091
1124
  * 붙어 있는데 접근성 트리의 `aria-current`는 계속 비어 있음). `render()`가 이
1092
1125
  * 값을 읽어 내부 `<a>`에 직접 옮긴다.
1093
1126
  *
@@ -67,11 +67,29 @@ 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
+ * ```
73
91
  */
74
- force?: boolean;
92
+ key?: (ctx: RouteContext) => string;
75
93
  /**
76
94
  * 경로 매칭시 대소문자 구분 여부
77
95
  * @default false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/router",
3
- "version": "0.11.5",
3
+ "version": "0.13.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,6 +75,8 @@
75
75
  "@types/react": "^19.2.17",
76
76
  "@types/react-dom": "^19.2.3",
77
77
  "happy-dom": "^20.10.6",
78
+ "react": "^19.2.8",
79
+ "react-dom": "^19.2.8",
78
80
  "typescript": "^6.0.2",
79
81
  "vite": "^8.1.4",
80
82
  "vite-plugin-dts": "^5.0.3",
@@ -71,7 +71,7 @@ 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
75
 
76
76
  ## RouteContext Fields
77
77