@cosmicdrift/kumiko-renderer 0.208.1 → 0.208.2

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",
3
- "version": "0.208.1",
3
+ "version": "0.208.2",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.208.1",
19
- "@cosmicdrift/kumiko-headless": "0.208.1",
18
+ "@cosmicdrift/kumiko-framework": "0.208.2",
19
+ "@cosmicdrift/kumiko-headless": "0.208.2",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -25,7 +25,7 @@
25
25
  "@testing-library/react": "^16.3.2",
26
26
  "@types/react": "^19.2.14",
27
27
  "jsdom": "^29.1.1",
28
- "@cosmicdrift/kumiko-locale-de": "0.208.1"
28
+ "@cosmicdrift/kumiko-locale-de": "0.208.2"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",
@@ -0,0 +1,152 @@
1
+ // fw#2216: a projectionList query handler that doesn't honor the PagedRows
2
+ // contract ({ rows, nextCursor }) used to fall through `rowsQuery.data?.rows
3
+ // ?? []` and silently render an empty table with HTTP 200 (prod bug on
4
+ // /session-list). This renders the real path (KumikoScreen →
5
+ // ProjectionListBody → RenderList) under a stub dispatcher returning a bare
6
+ // array, and proves the renderer-level shape guard surfaces an error banner
7
+ // instead of an empty list — plus that a correctly-shaped response still
8
+ // renders rows.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
12
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
13
+ import { render, waitFor } from "@testing-library/react";
14
+ import type { ComponentType, ReactNode } from "react";
15
+ import { DispatcherProvider } from "../../context/dispatcher-context";
16
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
17
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
18
+ import {
19
+ type BannerProps,
20
+ type CorePrimitives,
21
+ type DataTableProps,
22
+ PrimitivesProvider,
23
+ } from "../../primitives";
24
+ import type { FeatureSchema } from "../feature-schema";
25
+ import { KumikoScreen } from "../kumiko-screen";
26
+ import type { NavApi } from "../nav";
27
+ import { NavProvider } from "../nav";
28
+
29
+ let capturedProps: DataTableProps | undefined;
30
+ const captureDataTable: ComponentType<DataTableProps> = (props) => {
31
+ capturedProps = props;
32
+ return null;
33
+ };
34
+ const getCapturedProps = (): DataTableProps | undefined => capturedProps;
35
+ const noop = (): ReactNode => null;
36
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
37
+
38
+ // passChildren would drop testId — the bad-shape test asserts on which
39
+ // banner rendered, so it needs a real (if minimal) wrapper element.
40
+ const TestBanner: ComponentType<BannerProps> = ({ children, testId }) => (
41
+ <div data-testid={testId}>{children}</div>
42
+ );
43
+
44
+ const testPrimitives: CorePrimitives = {
45
+ Button: noop,
46
+ Banner: TestBanner,
47
+ Field: passChildren,
48
+ Input: noop,
49
+ DataTable: captureDataTable,
50
+ Form: passChildren,
51
+ Section: passChildren,
52
+ Card: passChildren,
53
+ Grid: passChildren,
54
+ GridCell: passChildren,
55
+ Text: passChildren,
56
+ Heading: noop,
57
+ Dialog: noop,
58
+ Modal: noop,
59
+ Lightbox: noop,
60
+ ConfigSourceBadge: noop,
61
+ ConfigCascadeView: noop,
62
+ Link: noop,
63
+ };
64
+
65
+ function stubDispatcher(queryResult: unknown): Dispatcher {
66
+ return {
67
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
68
+ query: (async () => ({ isSuccess: true, data: queryResult })) as unknown as Dispatcher["query"],
69
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
70
+ statusStore: {
71
+ getState: () => "online",
72
+ subscribe: () => () => {},
73
+ } as unknown as Dispatcher["statusStore"],
74
+ async *stream() {},
75
+ pendingWrites: () => [],
76
+ pendingFiles: () => [],
77
+ };
78
+ }
79
+
80
+ function buildSchema(screen: ProjectionListScreenDefinition): FeatureSchema {
81
+ return {
82
+ featureName: "ledger",
83
+ entities: {},
84
+ screens: [screen],
85
+ } as FeatureSchema;
86
+ }
87
+
88
+ const nav: NavApi = {
89
+ route: { screenId: "ledger:screen:schedule-list" },
90
+ navigate: () => {},
91
+ replace: () => {},
92
+ hrefFor: () => "",
93
+ searchParams: {},
94
+ setSearchParams: () => {},
95
+ };
96
+
97
+ function renderProjectionList(
98
+ screen: ProjectionListScreenDefinition,
99
+ queryResult: unknown,
100
+ ): ReturnType<typeof render> {
101
+ return render(
102
+ <LocaleProvider
103
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
104
+ fallbackBundles={[kumikoDefaultTranslations]}
105
+ >
106
+ <DispatcherProvider dispatcher={stubDispatcher(queryResult)}>
107
+ <NavProvider value={nav}>
108
+ <PrimitivesProvider value={testPrimitives}>
109
+ <KumikoScreen schema={buildSchema(screen)} qn="ledger:screen:schedule-list" />
110
+ </PrimitivesProvider>
111
+ </NavProvider>
112
+ </DispatcherProvider>
113
+ </LocaleProvider>,
114
+ );
115
+ }
116
+
117
+ const screen: ProjectionListScreenDefinition = {
118
+ id: "schedule-list",
119
+ type: "projectionList",
120
+ query: "ledger:query:schedule:list",
121
+ columns: ["description"],
122
+ };
123
+
124
+ describe("ProjectionListBody — PagedRows shape guard (fw#2216)", () => {
125
+ test("a bare-array query response renders the bad-shape error banner, not an empty table", async () => {
126
+ capturedProps = undefined;
127
+ const { container } = renderProjectionList(screen, [{ id: "1", description: "acme" }]);
128
+
129
+ const banner = await waitFor(() => {
130
+ const el = container.querySelector('[data-testid="kumiko-screen-projection-list-bad-shape"]');
131
+ if (el === null) throw new Error("bad-shape banner not rendered");
132
+ return el;
133
+ });
134
+ expect(banner.textContent).toContain("schedule-list");
135
+ expect(banner.textContent).toContain("ledger:query:schedule:list");
136
+ expect(getCapturedProps()).toBeUndefined();
137
+ });
138
+
139
+ test("a correct { rows, nextCursor } response renders the rows, no error banner", async () => {
140
+ capturedProps = undefined;
141
+ const { container } = renderProjectionList(screen, {
142
+ rows: [{ id: "1", description: "acme" }],
143
+ nextCursor: null,
144
+ });
145
+
146
+ await waitFor(() => expect(getCapturedProps()).toBeDefined());
147
+ expect(getCapturedProps()?.rows).toHaveLength(1);
148
+ expect(
149
+ container.querySelector('[data-testid="kumiko-screen-projection-list-bad-shape"]'),
150
+ ).toBeNull();
151
+ });
152
+ });
@@ -1380,7 +1380,7 @@ function ProjectionListBody({
1380
1380
  readonly translate?: Translate;
1381
1381
  readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
1382
1382
  }): ReactNode {
1383
- const { Banner } = usePrimitives();
1383
+ const { Banner, Text } = usePrimitives();
1384
1384
  const t = useTranslation();
1385
1385
  const nav = useNav();
1386
1386
  const dispatcher = useOptionalDispatcher();
@@ -1560,6 +1560,21 @@ function ProjectionListBody({
1560
1560
  );
1561
1561
  }
1562
1562
 
1563
+ // rowsQuery.data is typed as PagedRows but the query handler is free to
1564
+ // return anything at runtime (fw#2216: a bare array silently rendered an
1565
+ // empty table instead of surfacing the mismatch); a system-boundary cast
1566
+ // is needed to inspect the actual wire shape before trusting it.
1567
+ const rawRowsData = rowsQuery.data as unknown as { readonly rows?: unknown } | null;
1568
+ if (rawRowsData !== null && !Array.isArray(rawRowsData.rows)) {
1569
+ return (
1570
+ <Banner padded variant="error" testId="kumiko-screen-projection-list-bad-shape">
1571
+ Screen <Text variant="code">{screen.id}</Text> query{" "}
1572
+ <Text variant="code">{screen.query}</Text> did not return a PagedRows envelope; a
1573
+ projectionList query must return <Text variant="code">{"{ rows, nextCursor }"}</Text>.
1574
+ </Banner>
1575
+ );
1576
+ }
1577
+
1563
1578
  const rowClickAction = screen.rowActions?.find(
1564
1579
  (a): a is RowActionNavigate => a.kind === "navigate" && a.rowClick === true,
1565
1580
  );