@cosmicdrift/kumiko-renderer 0.239.0 → 0.241.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",
3
- "version": "0.239.0",
3
+ "version": "0.241.0",
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.239.0",
19
- "@cosmicdrift/kumiko-headless": "0.239.0",
18
+ "@cosmicdrift/kumiko-framework": "0.241.0",
19
+ "@cosmicdrift/kumiko-headless": "0.241.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.239.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.241.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { synthesizeProjectionDetailScreen } from "../projection-detail-shim";
4
+
5
+ // Regression for the writeForm extension (fw editable-detail-screens): the
6
+ // hard readOnly:true enforcement in synthesizeProjectionDetailScreen must
7
+ // still apply to every ordinary fields-section, unchanged — a writeForm
8
+ // section is the one deliberate exception (isFieldsEditSection excludes it,
9
+ // same as relatedList/extension), it must pass through editable.
10
+ describe("synthesizeProjectionDetailScreen", () => {
11
+ test("forces readOnly:true on a fields section, but leaves a writeForm section's own readOnly untouched", () => {
12
+ const screen: ProjectionDetailScreenDefinition = {
13
+ id: "order-detail",
14
+ type: "projectionDetail",
15
+ query: "orders:query:order:detail",
16
+ layout: {
17
+ sections: [
18
+ { title: "Basics", fields: [{ field: "name", readOnly: false }] },
19
+ {
20
+ kind: "writeForm",
21
+ title: "Add note",
22
+ fieldDefs: { note: { type: "text" } },
23
+ fields: [{ field: "note", readOnly: false }],
24
+ handler: "orders:write:add-note",
25
+ },
26
+ ],
27
+ },
28
+ };
29
+
30
+ const result = synthesizeProjectionDetailScreen(screen);
31
+
32
+ const [fieldsSection, writeFormSection] = result.layout.sections;
33
+ if (fieldsSection === undefined || !("fields" in fieldsSection) || "kind" in fieldsSection) {
34
+ throw new Error("expected the first section to stay a plain fields section");
35
+ }
36
+ expect(fieldsSection.fields[0]).toMatchObject({ field: "name", readOnly: true });
37
+
38
+ if (writeFormSection === undefined || writeFormSection.kind !== "writeForm") {
39
+ throw new Error("expected the second section to stay kind: writeForm, untouched");
40
+ }
41
+ expect(writeFormSection.fields[0]).toEqual({ field: "note", readOnly: false });
42
+ });
43
+ });
@@ -0,0 +1,191 @@
1
+ // fw#2312: `singleton: true` marks a projectionDetail screen whose query
2
+ // determines the shown row from the caller's session/context instead of a
3
+ // row id in the path (a self-service "my profile"/"my data" screen has no
4
+ // row to link to). Before this flag, ProjectionDetailBody always rendered
5
+ // the "needs a row id in the path" error banner when `entityId` was
6
+ // undefined (kumiko-screen.tsx) — both user-profile and user-data-rights'
7
+ // privacy-center screen hit exactly that, unrenderable, until the flag
8
+ // existed (fw#2312 bug report). This proves the fix, the no-flag regression
9
+ // case stays covered, and that a stray/spoofed path id never reaches the
10
+ // query under singleton (renderer-side half of the security requirement —
11
+ // the boot-validator half lives in framework's projection-detail-
12
+ // singleton.test.ts).
13
+
14
+ import { describe, expect, test } from "bun:test";
15
+ import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
16
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
17
+ import { render, waitFor } from "@testing-library/react";
18
+ import type { ComponentType, ReactNode } from "react";
19
+ import { DispatcherProvider } from "../../context/dispatcher-context";
20
+ import { UserRolesProvider } from "../../context/user-roles-context";
21
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
22
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
23
+ import {
24
+ type BannerProps,
25
+ type CorePrimitives,
26
+ type FormProps,
27
+ PrimitivesProvider,
28
+ } from "../../primitives";
29
+ import { AppFeaturesProvider } from "../app-features-context";
30
+ import type { FeatureSchema } from "../feature-schema";
31
+ import { KumikoScreen } from "../kumiko-screen";
32
+ import type { NavApi } from "../nav";
33
+ import { NavProvider } from "../nav";
34
+
35
+ const noop = (): ReactNode => null;
36
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
37
+
38
+ const FormWithActions: ComponentType<FormProps> = ({ children }) => (
39
+ <div data-testid="form-body">{children}</div>
40
+ );
41
+
42
+ const TestBanner: ComponentType<BannerProps> = ({ children, testId }) => (
43
+ <div data-testid={testId}>{children}</div>
44
+ );
45
+
46
+ const testPrimitives: CorePrimitives = {
47
+ Button: noop,
48
+ Banner: TestBanner,
49
+ Field: passChildren,
50
+ Input: noop,
51
+ DataTable: noop,
52
+ Form: FormWithActions,
53
+ Section: passChildren,
54
+ Card: passChildren,
55
+ Grid: passChildren,
56
+ GridCell: passChildren,
57
+ Text: passChildren,
58
+ Heading: noop,
59
+ Dialog: noop,
60
+ Modal: noop,
61
+ Lightbox: noop,
62
+ ConfigSourceBadge: noop,
63
+ ConfigCascadeView: noop,
64
+ Link: noop,
65
+ };
66
+
67
+ function stubDispatcher(
68
+ record: Readonly<Record<string, unknown>> | null,
69
+ queries: Array<{ type: string; payload: unknown }>,
70
+ ): Dispatcher {
71
+ return {
72
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
73
+ query: (async (type: string, payload: unknown) => {
74
+ queries.push({ type, payload });
75
+ return { isSuccess: true, data: record };
76
+ }) as unknown as Dispatcher["query"],
77
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
78
+ statusStore: {
79
+ getState: () => "online",
80
+ subscribe: () => () => {},
81
+ } as unknown as Dispatcher["statusStore"],
82
+ async *stream() {},
83
+ pendingWrites: () => [],
84
+ pendingFiles: () => [],
85
+ };
86
+ }
87
+
88
+ function meScreen(
89
+ overrides?: Partial<ProjectionDetailScreenDefinition>,
90
+ ): FeatureSchema["screens"][number] {
91
+ return {
92
+ id: "profile",
93
+ type: "projectionDetail",
94
+ query: "app:query:me",
95
+ layout: { sections: [{ title: "s", fields: ["name"] }] },
96
+ ...overrides,
97
+ } as FeatureSchema["screens"][number];
98
+ }
99
+
100
+ function renderScreen(opts: {
101
+ readonly screen: FeatureSchema["screens"][number];
102
+ readonly entityId?: string;
103
+ readonly queries: Array<{ type: string; payload: unknown }>;
104
+ readonly record?: Readonly<Record<string, unknown>> | null;
105
+ }): ReturnType<typeof render> {
106
+ const schema: FeatureSchema = { featureName: "app", entities: {}, screens: [opts.screen] };
107
+ const navApi: NavApi = {
108
+ route: { screenId: "app:screen:profile" },
109
+ navigate: () => {},
110
+ replace: () => {},
111
+ hrefFor: () => "",
112
+ searchParams: {},
113
+ setSearchParams: () => {},
114
+ };
115
+ return render(
116
+ <LocaleProvider
117
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
118
+ fallbackBundles={[kumikoDefaultTranslations]}
119
+ >
120
+ <DispatcherProvider
121
+ dispatcher={stubDispatcher(
122
+ opts.record !== undefined ? opts.record : { id: "u1", name: "Ada" },
123
+ opts.queries,
124
+ )}
125
+ >
126
+ <AppFeaturesProvider features={[schema]}>
127
+ <UserRolesProvider roles={[]}>
128
+ <NavProvider value={navApi}>
129
+ <PrimitivesProvider value={testPrimitives}>
130
+ <KumikoScreen
131
+ schema={schema}
132
+ qn="app:screen:profile"
133
+ {...(opts.entityId !== undefined && { entityId: opts.entityId })}
134
+ />
135
+ </PrimitivesProvider>
136
+ </NavProvider>
137
+ </UserRolesProvider>
138
+ </AppFeaturesProvider>
139
+ </DispatcherProvider>
140
+ </LocaleProvider>,
141
+ );
142
+ }
143
+
144
+ describe("projectionDetail singleton (fw#2312)", () => {
145
+ test("singleton + no path id renders the record instead of the missing-id banner", async () => {
146
+ const queries: Array<{ type: string; payload: unknown }> = [];
147
+ const { queryByTestId, getByTestId } = renderScreen({
148
+ screen: meScreen({ singleton: true }),
149
+ queries,
150
+ });
151
+
152
+ await waitFor(() => expect(getByTestId("form-body")).toBeTruthy());
153
+ expect(queryByTestId("kumiko-screen-projection-detail-missing-id")).toBeNull();
154
+ expect(queries).toEqual([{ type: "app:query:me", payload: {} }]);
155
+ });
156
+
157
+ test("no singleton flag + no path id still shows the missing-id banner (no regression)", async () => {
158
+ const queries: Array<{ type: string; payload: unknown }> = [];
159
+ const { getByTestId } = renderScreen({ screen: meScreen(), queries });
160
+
161
+ await waitFor(() =>
162
+ expect(getByTestId("kumiko-screen-projection-detail-missing-id")).toBeTruthy(),
163
+ );
164
+ });
165
+
166
+ test("singleton ignores a stray path id — the query never receives it", async () => {
167
+ const queries: Array<{ type: string; payload: unknown }> = [];
168
+ const { getByTestId } = renderScreen({
169
+ screen: meScreen({ singleton: true }),
170
+ entityId: "attacker-controlled-id",
171
+ queries,
172
+ });
173
+
174
+ await waitFor(() => expect(getByTestId("form-body")).toBeTruthy());
175
+ expect(queries).toEqual([{ type: "app:query:me", payload: {} }]);
176
+ });
177
+
178
+ test("singleton + record-not-found banner never shows the (ignored) path id", async () => {
179
+ const queries: Array<{ type: string; payload: unknown }> = [];
180
+ const { getByTestId } = renderScreen({
181
+ screen: meScreen({ singleton: true }),
182
+ entityId: "99",
183
+ record: null,
184
+ queries,
185
+ });
186
+
187
+ const banner = await waitFor(() => getByTestId("kumiko-screen-record-missing"));
188
+ expect(banner.textContent).toBe("Record not found.");
189
+ expect(banner.textContent).not.toContain("99");
190
+ });
191
+ });
@@ -6,14 +6,11 @@ import type {
6
6
  EntityDefinition,
7
7
  EntityEditScreenDefinition,
8
8
  EntityListScreenDefinition,
9
- IconKey,
10
9
  ListFacetSpec,
11
10
  ProjectionDetailScreenDefinition,
12
11
  ProjectionListScreenDefinition,
13
12
  RowAction,
14
13
  RowActionNavigate,
15
- RowActionWriteHandler,
16
- RowFieldExtractor,
17
14
  ScreenDefinition,
18
15
  ToolbarAction,
19
16
  } from "@cosmicdrift/kumiko-framework/ui-types";
@@ -39,8 +36,6 @@ import { useTranslation } from "../i18n";
39
36
  import {
40
37
  type DataTableFacet,
41
38
  type DataTableRowAction,
42
- type DataTableRowActionMode,
43
- shouldRenderActionsIconOnly,
44
39
  statusToneForValue,
45
40
  usePrimitives,
46
41
  } from "../primitives";
@@ -60,81 +55,20 @@ import {
60
55
  import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
61
56
  import { lastSegment, toKebab } from "./qn";
62
57
  import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
58
+ import {
59
+ buildProjectionRowActions,
60
+ evalRowExtractor,
61
+ isWriteHandlerRowAction,
62
+ refetchAfterWrite,
63
+ resolveActionIcon,
64
+ rowActionModeFor,
65
+ runProjectionRowNavigate,
66
+ stringifyNavParams,
67
+ } from "./row-actions";
63
68
  import { screenAccessAllows } from "./screen-access";
64
69
  import { SecretsEditBody } from "./secrets-edit-body";
65
70
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
66
71
 
67
- function evalRowExtractor(
68
- extractor: RowFieldExtractor,
69
- row: Record<string, unknown>,
70
- ): Record<string, unknown> {
71
- if ("pick" in extractor) {
72
- return Object.fromEntries(extractor.pick.map((f) => [f, row[f]]));
73
- }
74
- return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
75
- }
76
-
77
- function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHandler {
78
- return action.kind === "writeHandler" || action.kind === undefined;
79
- }
80
-
81
- // Part B (fw-ui-defaults): id-derived default icon for actions that never
82
- // declared one — a screen author still gets a recognizable glyph instead of
83
- // a bare label. Checked against the actually registered IconKey vocabulary
84
- // (nav-icon.ts) — no entry for verbs without a matching icon (e.g. "start",
85
- // "pause").
86
- const ACTION_ICON_BY_ID: Readonly<Partial<Record<string, IconKey>>> = {
87
- delete: "trash",
88
- edit: "pencil",
89
- create: "plus",
90
- new: "plus",
91
- add: "plus",
92
- view: "eye",
93
- open: "eye",
94
- cancel: "x",
95
- reject: "x",
96
- complete: "check",
97
- resolve: "check",
98
- approve: "check",
99
- archive: "archive",
100
- publish: "upload",
101
- duplicate: "copy",
102
- copy: "copy",
103
- download: "download",
104
- refresh: "refresh",
105
- retry: "refresh",
106
- settings: "settings",
107
- share: "share",
108
- send: "send",
109
- };
110
-
111
- // Ids are kebab-case (RowAction.id doc) — a compound id whose full form has
112
- // no entry falls back to its last segment ("order-ship" -> "ship").
113
- function kebabLastSegment(id: string): string {
114
- const idx = id.lastIndexOf("-");
115
- return idx === -1 ? id : id.slice(idx + 1);
116
- }
117
-
118
- // Resolution order: author-declared `icon` wins, then the id-derived
119
- // default (full id, then its last kebab segment). `declared` is `undefined`
120
- // for ToolbarAction, which has no author-facing icon field.
121
- function resolveActionIcon(id: string, declared?: IconKey): IconKey | undefined {
122
- if (declared !== undefined) return declared;
123
- return ACTION_ICON_BY_ID[id] ?? ACTION_ICON_BY_ID[kebabLastSegment(id)];
124
- }
125
-
126
- // Row-action column mode for a resolved action set: a group where every
127
- // member carries an icon renders inline so `shouldRenderActionsIconOnly`
128
- // can collapse it to icon-only buttons (fw#2580). Anything else keeps the
129
- // DataTable's adaptive default (kebab past two actions) — inline text
130
- // buttons for an icon-less group are the very thing the collapse avoids.
131
- function rowActionModeFor(
132
- actions: readonly DataTableRowAction[] | undefined,
133
- ): DataTableRowActionMode | undefined {
134
- if (actions === undefined || !shouldRenderActionsIconOnly(actions)) return undefined;
135
- return "inline";
136
- }
137
-
138
72
  // KumikoScreen picks up a ScreenDefinition from the schema by qn and
139
73
  // routes it to the right renderer based on `screen.type`. Command
140
74
  // qualification (`<feature>:write:<entity>:create` etc.) happens here
@@ -1106,26 +1040,10 @@ function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[
1106
1040
  // passes through any field present in entity.fields, matching its
1107
1041
  // pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
1108
1042
 
1109
- function stringifyNavParams(params: Record<string, unknown>): Record<string, string | null> {
1110
- const out: Record<string, string | null> = {};
1111
- for (const [k, v] of Object.entries(params)) {
1112
- out[k] =
1113
- v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
1114
- }
1115
- return out;
1116
- }
1117
-
1118
1043
  function isFacetI18nKey(label: string): boolean {
1119
1044
  return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
1120
1045
  }
1121
1046
 
1122
- async function refetchAfterWrite(refetch: () => Promise<unknown>): Promise<void> {
1123
- await refetch().catch((err: unknown) => {
1124
- // biome-ignore lint/suspicious/noConsole: refetch must not poison the write-action error path
1125
- console.error("kumiko-screen: refetch after write action failed", err);
1126
- });
1127
- }
1128
-
1129
1047
  function buildFilterPayload(
1130
1048
  urlFilters: Readonly<Record<string, readonly string[]>>,
1131
1049
  typeOf: (field: string) => string | undefined,
@@ -1953,97 +1871,31 @@ function ProjectionListBody({
1953
1871
 
1954
1872
  const filterFacets = useMemo<DataTableFacet[]>(() => buildFilterFacets(facetSpecs), [facetSpecs]);
1955
1873
 
1874
+ // Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
1875
+ // resolution happens in the NavApi impl. Unlike there: NO row["id"]
1876
+ // fallback — projectionList rows come from an arbitrary query projection
1877
+ // with no guaranteed "id" field. The boot validator therefore enforces an
1878
+ // explicit entityId for projectionList entity targets. Same helper
1879
+ // relatedList's rowActions reuse (related-list-section.tsx) — a
1880
+ // projectionDetail relatedList row has the identical "no guaranteed id"
1881
+ // shape.
1956
1882
  const runNavigate = useCallback(
1957
- (action: RowActionNavigate, row: ListRowViewModel) => {
1958
- if (action.entity !== undefined) {
1959
- // Entity-Targets (fw#2228) — siehe EntityListBody.runNavigate für die
1960
- // Begründung, warum die Auflösung in der NavApi-Impl passiert. Anders
1961
- // als dort: KEIN row["id"]-Fallback — projectionList-Rows kommen aus
1962
- // einer beliebigen Query-Projection ohne garantiertes "id"-Feld
1963
- // (gleiche Begründung wie beim screen-Target unten). Der Boot-
1964
- // Validator erzwingt deshalb einen expliziten entityId für
1965
- // projectionList-entity-Targets.
1966
- const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
1967
- if (id === "") return;
1968
- nav.navigate({ entity: action.entity, id });
1969
- } else if (action.screen !== undefined) {
1970
- const entityId =
1971
- action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1972
- nav.navigate({
1973
- screenId: action.screen,
1974
- ...(entityId !== undefined && entityId !== "" && { entityId }),
1975
- });
1976
- } else {
1977
- return;
1978
- }
1979
- const params =
1980
- action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1981
- if (params !== undefined) {
1982
- nav.setSearchParams(stringifyNavParams(params));
1983
- }
1984
- },
1883
+ (action: RowActionNavigate, row: ListRowViewModel) =>
1884
+ runProjectionRowNavigate(nav, action, row),
1985
1885
  [nav],
1986
1886
  );
1987
1887
 
1988
- const rowActions = useMemo((): readonly DataTableRowAction[] | undefined => {
1989
- if (screen.rowActions === undefined) return undefined;
1990
- const out: DataTableRowAction[] = [];
1991
- for (const action of screen.rowActions) {
1992
- if (action.kind === "navigate") {
1993
- const navigateAction = action;
1994
- const visible = action.visible;
1995
- const actionIcon = resolveActionIcon(action.id, action.icon);
1996
- out.push({
1997
- id: action.id,
1998
- label: effectiveTranslate(action.label),
1999
- ...(action.style !== undefined && { style: action.style }),
2000
- ...(actionIcon !== undefined && { icon: actionIcon }),
2001
- onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
2002
- ...(visible !== undefined && {
2003
- isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
2004
- }),
2005
- });
2006
- continue;
2007
- }
2008
- // writeHandler (default-kind) — gleicher Dispatch-Pfad wie entityList:
2009
- // Failure-Result MUSS zum Error werden (sonst schließt der Confirm-
2010
- // Dialog kommentarlos).
2011
- if (dispatcher === undefined) continue;
2012
- const writeAction = action;
2013
- const writeVisible = writeAction.visible;
2014
- out.push({
2015
- id: writeAction.id,
2016
- label: effectiveTranslate(writeAction.label),
2017
- ...(writeAction.style !== undefined && { style: writeAction.style }),
2018
- icon: resolveActionIcon(writeAction.id, writeAction.icon),
2019
- ...(writeAction.confirm !== undefined && {
2020
- confirm: effectiveTranslate(writeAction.confirm),
2021
- }),
2022
- ...(writeAction.confirmLabel !== undefined && {
2023
- confirmLabel: effectiveTranslate(writeAction.confirmLabel),
2024
- }),
2025
- onTrigger: async (row: ListRowViewModel) => {
2026
- const payload =
2027
- writeAction.payload !== undefined
2028
- ? evalRowExtractor(writeAction.payload, row.values)
2029
- : { id: row.values["id"] };
2030
- const result = await dispatcher.write(writeAction.handler, payload);
2031
- if (!result.isSuccess) {
2032
- throw new WriteFailedError(
2033
- result.error,
2034
- dispatcherErrorText(result.error, effectiveTranslate),
2035
- );
2036
- }
2037
- // Same refetch as EntityListBody's rowActions above.
2038
- await refetchAfterWrite(rowsQuery.refetch);
2039
- },
2040
- ...(writeVisible !== undefined && {
2041
- isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
2042
- }),
2043
- });
2044
- }
2045
- return out.length > 0 ? out : undefined;
2046
- }, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher, rowsQuery.refetch]);
1888
+ const rowActions = useMemo(
1889
+ () =>
1890
+ buildProjectionRowActions({
1891
+ rowActions: screen.rowActions,
1892
+ translate: effectiveTranslate,
1893
+ dispatcher,
1894
+ nav,
1895
+ refetch: rowsQuery.refetch,
1896
+ }),
1897
+ [screen.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch],
1898
+ );
2047
1899
 
2048
1900
  // Same icon-only collapse as entityList (fw#2580) — projectionList rows go
2049
1901
  // through the identical RenderList/DataTable path.
@@ -2224,6 +2076,12 @@ function ProjectionDetailBody({
2224
2076
  const effectiveTranslate = translate ?? t;
2225
2077
  const nav = useNav();
2226
2078
  const idParam = screen.idParam ?? "id";
2079
+ // Singleton screens ignore whatever id the path happens to carry — the
2080
+ // server picks the row from session/context, so forwarding a path id
2081
+ // (even a stray/spoofed one) into the query or into extension sections'
2082
+ // entityId fallback (resolveExtensionEntityId in render-edit.tsx) would
2083
+ // let a client-controlled value leak where none is meant to reach.
2084
+ const effectiveEntityId = screen.singleton === true ? undefined : entityId;
2227
2085
  const isTabsMode = screen.layout.mode === "tabs";
2228
2086
  const activeSection = useMemo(() => {
2229
2087
  if (!isTabsMode || Tabs === undefined) return undefined;
@@ -2252,9 +2110,21 @@ function ProjectionDetailBody({
2252
2110
  }, [screen, activeSection]);
2253
2111
  const detailQuery = useQuery<Readonly<Record<string, unknown>>>(
2254
2112
  screen.query,
2255
- entityId !== undefined ? { [idParam]: entityId } : {},
2113
+ effectiveEntityId !== undefined ? { [idParam]: effectiveEntityId } : {},
2256
2114
  );
2257
2115
 
2116
+ // A writeForm section's handler creates a new record (see EditWriteFormSection's
2117
+ // doc) — both the outer record (header/metrics/other sections) and a sibling
2118
+ // relatedList section's own independently-fetched rows must reflect it. A
2119
+ // query refetch alone only refreshes `record` below; bumping this into
2120
+ // <RenderEdit>'s `key` forces a full remount so RelatedListSection's own
2121
+ // useQuery call re-runs too (it has no `live` subscription of its own).
2122
+ const [reloadNonce, setReloadNonce] = useState(0);
2123
+ const reloadDetail = useCallback(async () => {
2124
+ await detailQuery.refetch();
2125
+ setReloadNonce((n) => n + 1);
2126
+ }, [detailQuery.refetch]);
2127
+
2258
2128
  // Default edit action (fw#2166): resolved cross-feature over ALL mounted
2259
2129
  // features, not just this feature's own schema — detailFor itself is
2260
2130
  // resolved cross-feature by the boot-validator (detail-screens.ts), and
@@ -2293,9 +2163,12 @@ function ProjectionDetailBody({
2293
2163
  label: effectiveTranslate("kumiko.actions.edit"),
2294
2164
  icon: resolveActionIcon("edit"),
2295
2165
  onPress: () =>
2296
- nav.navigate({ screenId: targetScreenId, ...(entityId !== undefined && { entityId }) }),
2166
+ nav.navigate({
2167
+ screenId: targetScreenId,
2168
+ ...(effectiveEntityId !== undefined && { entityId: effectiveEntityId }),
2169
+ }),
2297
2170
  };
2298
- }, [editScreen, effectiveTranslate, nav, entityId]);
2171
+ }, [editScreen, effectiveTranslate, nav, effectiveEntityId]);
2299
2172
 
2300
2173
  const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
2301
2174
  const record = detailQuery.data ?? {};
@@ -2418,7 +2291,7 @@ function ProjectionDetailBody({
2418
2291
  detailQuery.refetch,
2419
2292
  ]);
2420
2293
 
2421
- if (entityId === undefined) {
2294
+ if (effectiveEntityId === undefined && screen.singleton !== true) {
2422
2295
  return (
2423
2296
  <Banner padded variant="error" testId="kumiko-screen-projection-detail-missing-id">
2424
2297
  Screen <Text variant="code">{screen.id}</Text> (projectionDetail) needs a row id in the path
@@ -2444,7 +2317,13 @@ function ProjectionDetailBody({
2444
2317
  if (!record) {
2445
2318
  return (
2446
2319
  <Banner padded variant="error" testId="kumiko-screen-record-missing">
2447
- Record <Text variant="code">{entityId}</Text> not found.
2320
+ {screen.singleton === true ? (
2321
+ "Record not found."
2322
+ ) : (
2323
+ <>
2324
+ Record <Text variant="code">{entityId}</Text> not found.
2325
+ </>
2326
+ )}
2448
2327
  </Banner>
2449
2328
  );
2450
2329
  }
@@ -2528,12 +2407,14 @@ function ProjectionDetailBody({
2528
2407
  );
2529
2408
  return (
2530
2409
  <RenderEdit
2410
+ key={`${entityId}:${reloadNonce}`}
2531
2411
  screen={detailScreen}
2532
2412
  entity={entity}
2533
2413
  featureName={schema.featureName}
2534
2414
  initial={record as FormValues}
2535
- entityId={entityId}
2415
+ entityId={effectiveEntityId}
2536
2416
  customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
2417
+ onReload={reloadDetail}
2537
2418
  {...(headerActions !== undefined && { actions: headerActions })}
2538
2419
  {...(translate !== undefined && { translate })}
2539
2420
  {...(hasTabs && { hideSectionTitles: true })}