@cosmicdrift/kumiko-renderer-web 0.201.0 → 0.202.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.201.0",
3
+ "version": "0.202.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.201.0",
20
- "@cosmicdrift/kumiko-headless": "0.201.0",
21
- "@cosmicdrift/kumiko-renderer": "0.201.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.202.0",
20
+ "@cosmicdrift/kumiko-headless": "0.202.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.202.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -576,10 +576,12 @@ describe("createKumikoApp", () => {
576
576
  route: { screenId: "task-list" },
577
577
  navigate: () => {},
578
578
  replace: () => {},
579
- hrefFor: (target) =>
580
- target.entityId !== undefined
579
+ hrefFor: (target) => {
580
+ if (!("screenId" in target)) return "";
581
+ return target.entityId !== undefined
581
582
  ? `/${target.screenId}/${target.entityId}`
582
- : `/${target.screenId}`,
583
+ : `/${target.screenId}`;
584
+ },
583
585
  searchParams: {},
584
586
  setSearchParams: () => {},
585
587
  };
@@ -6,7 +6,7 @@ import type {
6
6
  EntityListScreenDefinition,
7
7
  } from "@cosmicdrift/kumiko-framework/ui-types";
8
8
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
9
- import type { FeatureSchema, NavApi } from "@cosmicdrift/kumiko-renderer";
9
+ import type { FeatureSchema, NavApi, NavTarget } from "@cosmicdrift/kumiko-renderer";
10
10
  import {
11
11
  DispatcherProvider,
12
12
  ExtensionSectionsProvider,
@@ -812,12 +812,12 @@ describe("KumikoScreen", () => {
812
812
  const searchParamUpdates: Record<string, string | null>[] = [];
813
813
  const memoryNav = {
814
814
  route: { screenId: "task-list" },
815
- navigate: (target: { screenId: string }) => {
815
+ navigate: (target: NavTarget) => {
816
816
  calls.push({ kind: "navigate", value: target });
817
- navigateCalls.push(target);
817
+ if ("screenId" in target) navigateCalls.push(target);
818
818
  },
819
819
  replace: () => undefined,
820
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
820
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
821
821
  searchParams: {},
822
822
  setSearchParams: (u: Record<string, string | null>) => {
823
823
  calls.push({ kind: "setSearchParams", value: u });
@@ -941,9 +941,11 @@ describe("KumikoScreen", () => {
941
941
  const searchParamUpdates: Record<string, string | null>[] = [];
942
942
  const memoryNav = {
943
943
  route: { screenId: "task-list" },
944
- navigate: (target: { screenId: string; entityId?: string }) => navigateCalls.push(target),
944
+ navigate: (target: NavTarget) => {
945
+ if ("screenId" in target) navigateCalls.push(target);
946
+ },
945
947
  replace: () => undefined,
946
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
948
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
947
949
  searchParams: {},
948
950
  setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
949
951
  };
@@ -1000,9 +1002,11 @@ describe("KumikoScreen", () => {
1000
1002
  const navigateCalls: { screenId: string; entityId?: string }[] = [];
1001
1003
  const memoryNav = {
1002
1004
  route: { screenId: "task-list" },
1003
- navigate: (target: { screenId: string; entityId?: string }) => navigateCalls.push(target),
1005
+ navigate: (target: NavTarget) => {
1006
+ if ("screenId" in target) navigateCalls.push(target);
1007
+ },
1004
1008
  replace: () => undefined,
1005
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1009
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1006
1010
  searchParams: {},
1007
1011
  setSearchParams: () => undefined,
1008
1012
  };
@@ -1055,9 +1059,11 @@ describe("KumikoScreen", () => {
1055
1059
  const navigateCalls: { screenId: string; entityId?: string }[] = [];
1056
1060
  const memoryNav = {
1057
1061
  route: { screenId: "task-list" },
1058
- navigate: (target: { screenId: string; entityId?: string }) => navigateCalls.push(target),
1062
+ navigate: (target: NavTarget) => {
1063
+ if ("screenId" in target) navigateCalls.push(target);
1064
+ },
1059
1065
  replace: () => undefined,
1060
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1066
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1061
1067
  searchParams: {},
1062
1068
  setSearchParams: () => undefined,
1063
1069
  };
@@ -1114,9 +1120,11 @@ describe("KumikoScreen", () => {
1114
1120
  const searchParamUpdates: Record<string, string | null>[] = [];
1115
1121
  const memoryNav = {
1116
1122
  route: { screenId: "task-list" },
1117
- navigate: (target: { screenId: string }) => navigateCalls.push(target),
1123
+ navigate: (target: NavTarget) => {
1124
+ if ("screenId" in target) navigateCalls.push(target);
1125
+ },
1118
1126
  replace: () => undefined,
1119
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1127
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1120
1128
  searchParams: {},
1121
1129
  setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
1122
1130
  };
@@ -1170,9 +1178,11 @@ describe("KumikoScreen", () => {
1170
1178
  });
1171
1179
  const memoryNav = {
1172
1180
  route: { screenId: "task-list" },
1173
- navigate: (target: { screenId: string }) => navigateCalls.push(target),
1181
+ navigate: (target: NavTarget) => {
1182
+ if ("screenId" in target) navigateCalls.push(target);
1183
+ },
1174
1184
  replace: () => undefined,
1175
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1185
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1176
1186
  searchParams: {},
1177
1187
  setSearchParams: () => undefined,
1178
1188
  };
@@ -1306,7 +1316,7 @@ describe("KumikoScreen", () => {
1306
1316
  route: undefined,
1307
1317
  navigate: () => {},
1308
1318
  replace: () => {},
1309
- hrefFor: (t) => `/${t.screenId}`,
1319
+ hrefFor: (t) => ("screenId" in t ? `/${t.screenId}` : ""),
1310
1320
  searchParams: { "task-list.f.id": "r1,r2" },
1311
1321
  setSearchParams: () => {},
1312
1322
  };
@@ -1448,9 +1458,11 @@ describe("KumikoScreen", () => {
1448
1458
  });
1449
1459
  const memoryNav = {
1450
1460
  route: { screenId: "quick-add" },
1451
- navigate: (target: { screenId: string }) => navigateCalls.push(target),
1461
+ navigate: (target: NavTarget) => {
1462
+ if ("screenId" in target) navigateCalls.push(target);
1463
+ },
1452
1464
  replace: () => undefined,
1453
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1465
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1454
1466
  searchParams: {},
1455
1467
  setSearchParams: () => undefined,
1456
1468
  };
@@ -1492,9 +1504,11 @@ describe("KumikoScreen", () => {
1492
1504
  });
1493
1505
  const memoryNav = {
1494
1506
  route: { screenId: "quick-add" },
1495
- navigate: (target: { screenId: string }) => navigateCalls.push(target),
1507
+ navigate: (target: NavTarget) => {
1508
+ if ("screenId" in target) navigateCalls.push(target);
1509
+ },
1496
1510
  replace: () => undefined,
1497
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1511
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1498
1512
  searchParams: {},
1499
1513
  setSearchParams: () => undefined,
1500
1514
  };
@@ -1576,9 +1590,11 @@ describe("KumikoScreen", () => {
1576
1590
  const navigateCalls: { screenId: string }[] = [];
1577
1591
  const memoryNav = {
1578
1592
  route: { screenId: "quick-add" },
1579
- navigate: (target: { screenId: string }) => navigateCalls.push(target),
1593
+ navigate: (target: NavTarget) => {
1594
+ if ("screenId" in target) navigateCalls.push(target);
1595
+ },
1580
1596
  replace: () => undefined,
1581
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1597
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1582
1598
  searchParams: {},
1583
1599
  setSearchParams: () => undefined,
1584
1600
  };
@@ -1609,9 +1625,11 @@ describe("KumikoScreen", () => {
1609
1625
  const navigateCalls: { screenId: string }[] = [];
1610
1626
  const memoryNav = {
1611
1627
  route: { screenId: "quick-add" },
1612
- navigate: (target: { screenId: string }) => navigateCalls.push(target),
1628
+ navigate: (target: NavTarget) => {
1629
+ if ("screenId" in target) navigateCalls.push(target);
1630
+ },
1613
1631
  replace: () => undefined,
1614
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1632
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1615
1633
  searchParams: {},
1616
1634
  setSearchParams: () => undefined,
1617
1635
  };
@@ -1747,9 +1765,11 @@ describe("KumikoScreen", () => {
1747
1765
  });
1748
1766
  const memoryNav = {
1749
1767
  route: { screenId: "quick-add" },
1750
- navigate: (target: { screenId: string }) => navigateCalls.push(target),
1768
+ navigate: (target: NavTarget) => {
1769
+ if ("screenId" in target) navigateCalls.push(target);
1770
+ },
1751
1771
  replace: () => undefined,
1752
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1772
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1753
1773
  searchParams: {},
1754
1774
  setSearchParams: () => undefined,
1755
1775
  };
@@ -1825,9 +1845,11 @@ describe("KumikoScreen", () => {
1825
1845
  const navigateCalls: { screenId: string }[] = [];
1826
1846
  const memoryNav = {
1827
1847
  route: { screenId: "task-list" },
1828
- navigate: (target: { screenId: string }) => navigateCalls.push(target),
1848
+ navigate: (target: NavTarget) => {
1849
+ if ("screenId" in target) navigateCalls.push(target);
1850
+ },
1829
1851
  replace: () => undefined,
1830
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1852
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1831
1853
  searchParams: {},
1832
1854
  setSearchParams: () => undefined,
1833
1855
  };
@@ -1864,9 +1886,11 @@ describe("KumikoScreen", () => {
1864
1886
  const navigateCalls: { screenId: string }[] = [];
1865
1887
  const memoryNav = {
1866
1888
  route: { screenId: "task-list" },
1867
- navigate: (target: { screenId: string }) => navigateCalls.push(target),
1889
+ navigate: (target: NavTarget) => {
1890
+ if ("screenId" in target) navigateCalls.push(target);
1891
+ },
1868
1892
  replace: () => undefined,
1869
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1893
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1870
1894
  searchParams: {},
1871
1895
  setSearchParams: () => undefined,
1872
1896
  };
@@ -2209,7 +2233,7 @@ describe("KumikoScreen: actionForm extension-section", () => {
2209
2233
  route: { screenId: "post-update" },
2210
2234
  navigate: () => undefined,
2211
2235
  replace: () => undefined,
2212
- hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
2236
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
2213
2237
  searchParams: { incidentId: "inc-7" },
2214
2238
  setSearchParams: () => undefined,
2215
2239
  };
@@ -53,10 +53,12 @@ const stubNav: NavApi = {
53
53
  route: undefined,
54
54
  navigate: () => {},
55
55
  replace: () => {},
56
- hrefFor: (target) =>
57
- target.entityId !== undefined
56
+ hrefFor: (target) => {
57
+ if (!("screenId" in target)) return "";
58
+ return target.entityId !== undefined
58
59
  ? `/${target.screenId}/${target.entityId}`
59
- : `/${target.screenId}`,
60
+ : `/${target.screenId}`;
61
+ },
60
62
  searchParams: {},
61
63
  setSearchParams: () => {},
62
64
  };
@@ -177,8 +177,14 @@ export type CreateKumikoAppOptions = {
177
177
  * `{ hasWorkspaces: true }` aufgerufen — der Default-Hook nutzt das
178
178
  * um das URL-Pattern auf `/<workspace>/<screen>[/<entityId>]`
179
179
  * umzustellen. Eigene Adapter dürfen die Option ignorieren wenn ihr
180
- * Router das anders löst. */
181
- readonly navAdapter?: (options?: { readonly hasWorkspaces?: boolean }) => NavApi;
180
+ * Router das anders löst. `features` ist `app.features` — der
181
+ * Default-Hook braucht sie für resolveTarget() (ObjectTarget
182
+ * ScreenTarget); eigene Adapter die nie ein ObjectTarget bekommen,
183
+ * dürfen sie ignorieren. */
184
+ readonly navAdapter?: (options?: {
185
+ readonly hasWorkspaces?: boolean;
186
+ readonly features?: readonly FeatureSchema[];
187
+ }) => NavApi;
182
188
  };
183
189
 
184
190
  // Reads the dev-server-injected schema from the global. Guarded for
@@ -474,7 +480,10 @@ function BrowserNavBoot({
474
480
  }: {
475
481
  readonly app: AppSchema;
476
482
  readonly fallbackQn: string;
477
- readonly useNavApi: (options?: { readonly hasWorkspaces?: boolean }) => NavApi;
483
+ readonly useNavApi: (options?: {
484
+ readonly hasWorkspaces?: boolean;
485
+ readonly features?: readonly FeatureSchema[];
486
+ }) => NavApi;
478
487
  readonly hasWorkspaces: boolean;
479
488
  readonly translate?: Translate;
480
489
  readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
@@ -483,7 +492,7 @@ function BrowserNavBoot({
483
492
  readonly schema: AppSchema;
484
493
  }) => ReactNode;
485
494
  }): ReactNode {
486
- const navApi = useNavApi({ hasWorkspaces });
495
+ const navApi = useNavApi({ hasWorkspaces, features: app.features });
487
496
  const Shell = shell;
488
497
  const screen = (
489
498
  <RoutedScreen
package/src/app/nav.tsx CHANGED
@@ -11,10 +11,13 @@
11
11
  // Notify-Trigger (eigenes pushPath, popstate-Event), nicht generalisierbar.
12
12
 
13
13
  import {
14
+ type FeatureSchema,
14
15
  formatPath,
15
16
  type NavApi,
16
17
  type NavTarget,
17
18
  parsePath,
19
+ resolveTarget,
20
+ type ScreenTarget,
18
21
  useNav,
19
22
  } from "@cosmicdrift/kumiko-renderer";
20
23
  import {
@@ -66,6 +69,11 @@ function prependBasePath(path: string, basePath: string): string {
66
69
  const listeners = new Set<() => void>();
67
70
  let popstateWired = false;
68
71
 
72
+ // Stable identity for the omitted-features case — `?? []` would create a
73
+ // fresh array every render, busting the useMemo below on every render for
74
+ // every existing useBrowserNavApi() call site (none of which pass features).
75
+ const NO_FEATURES: readonly FeatureSchema[] = [];
76
+
69
77
  function ensurePopstateWired(): void {
70
78
  if (popstateWired) return;
71
79
  if (typeof window === "undefined") return;
@@ -174,6 +182,10 @@ function replacePath(path: string): void {
174
182
  export function useBrowserNavApi(options?: {
175
183
  readonly hasWorkspaces?: boolean;
176
184
  readonly basePath?: string;
185
+ /** Feature list ObjectTarget resolution needs (resolveTarget). Omit when
186
+ * the caller never passes an ObjectTarget — a ScreenTarget resolves
187
+ * without looking at the feature list at all. */
188
+ readonly features?: readonly FeatureSchema[];
177
189
  }): NavApi {
178
190
  const path = useSyncExternalStore(subscribe, readPath, () => "/");
179
191
  // Search wird über denselben Listener-Set notifiziert (popstate +
@@ -185,6 +197,7 @@ export function useBrowserNavApi(options?: {
185
197
  const basePath = useMemo(() => normalizeBasePath(options?.basePath), [options?.basePath]);
186
198
  const searchParams = useMemo(() => parseSearchParams(search), [search]);
187
199
  const inAppPath = useMemo(() => stripBasePath(path, basePath), [path, basePath]);
200
+ const features = options?.features ?? NO_FEATURES;
188
201
  return useMemo<NavApi>(() => {
189
202
  const route = inAppPath === undefined ? undefined : parsePath(inAppPath, hasWorkspaces);
190
203
  // NavTarget-Contract: workspaceId weglassen = aktueller Workspace
@@ -192,21 +205,24 @@ export function useBrowserNavApi(options?: {
192
205
  // landet `/screen-id` in parsePath(hasWorkspaces) als workspaceId,
193
206
  // WorkspaceShell sieht einen unbekannten Workspace und revertet
194
207
  // sofort auf den Default-Screen ("Klick tut nichts"-Prod-Bug).
195
- const inCurrentWorkspace = (target: NavTarget): NavTarget =>
208
+ const inCurrentWorkspace = (target: ScreenTarget): ScreenTarget =>
196
209
  hasWorkspaces && target.workspaceId === undefined && route?.workspaceId !== undefined
197
210
  ? { ...target, workspaceId: route.workspaceId }
198
211
  : target;
212
+ // resolveTarget runs first so an ObjectTarget never reaches formatPath —
213
+ // everything downstream (workspace-injection, path-building) only ever
214
+ // sees the ScreenTarget shape it already knew about.
215
+ const toPath = (target: NavTarget): string =>
216
+ prependBasePath(formatPath(inCurrentWorkspace(resolveTarget(features, target))), basePath);
199
217
  return {
200
218
  route,
201
- navigate: (target) =>
202
- pushPath(prependBasePath(formatPath(inCurrentWorkspace(target)), basePath)),
203
- replace: (target) =>
204
- replacePath(prependBasePath(formatPath(inCurrentWorkspace(target)), basePath)),
205
- hrefFor: (target) => prependBasePath(formatPath(inCurrentWorkspace(target)), basePath),
219
+ navigate: (target) => pushPath(toPath(target)),
220
+ replace: (target) => replacePath(toPath(target)),
221
+ hrefFor: (target) => toPath(target),
206
222
  searchParams,
207
223
  setSearchParams: applySearchParamUpdates,
208
224
  };
209
- }, [inAppPath, hasWorkspaces, basePath, searchParams]);
225
+ }, [inAppPath, hasWorkspaces, basePath, searchParams, features]);
210
226
  }
211
227
 
212
228
  // ---- KumikoLink (Anchor-basiert, nur Web) ----