@cosmicdrift/kumiko-renderer 0.396.0 → 0.398.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.
@@ -7,7 +7,7 @@ import { rendererWarn } from "../dev-console.js";
7
7
  import { applyFormatSpec } from "../headless/index.js";
8
8
  import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
9
9
  import { PAGE_SIZE_OPTIONS } from "../hooks/use-list-url-state.js";
10
- import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
10
+ import { referenceLookupSource, referenceRowsByIdsPayload } from "../hooks/use-reference-lookup.js";
11
11
  import { useTranslation } from "../i18n.js";
12
12
  import { usePrimitives } from "../primitives.js";
13
13
  import { ISO_DATE_PATTERN, isMoneyValue } from "./entity-edit-prefill.js";
@@ -139,14 +139,7 @@ export async function resolveSuccessMessageParams(screen, featureName, values, d
139
139
  return;
140
140
  const target = parseRefTarget(def.entity, featureName);
141
141
  const { queryQn, labelKey } = referenceLookupSource(target.featureName, target.entityName, def.labelField ?? "id");
142
- // Ask for exactly the chosen record so it cannot fall beyond the lookup cap: `ids` is the
143
- // by-id input of directory sources, `filters` that of entity list handlers. A source that
144
- // knows neither returns its capped list and the scan below still finds the row if present.
145
- const lookup = await dispatcher.query(queryQn, {
146
- limit: REFERENCE_LIST_LOOKUP_LIMIT,
147
- ids: [raw],
148
- filters: [{ field: "id", op: "in", value: [raw] }],
149
- });
142
+ const lookup = await dispatcher.query(queryQn, referenceRowsByIdsPayload([raw], REFERENCE_LIST_LOOKUP_LIMIT));
150
143
  if (!lookup.isSuccess)
151
144
  return;
152
145
  const row = lookup.data.rows.find((candidate) => candidate["id"] === raw);
@@ -4,9 +4,18 @@ import { computeDerivedCellValue, currencyDecimals, groupEmbeddedListIssues, rou
4
4
  import { pluralKey } from "../headless/plural-key.js";
5
5
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
6
6
  import { useQuery } from "../hooks/use-query.js";
7
- import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
7
+ import { referenceOptionSource, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
8
8
  import { useTranslation } from "../i18n.js";
9
9
  import { usePrimitives } from "../primitives.js";
10
+ function storedCellIds(rows, cellField) {
11
+ const ids = new Set();
12
+ for (const row of rows) {
13
+ const value = row[cellField];
14
+ if (typeof value === "string" && value !== "")
15
+ ids.add(value);
16
+ }
17
+ return [...ids].sort();
18
+ }
10
19
  function withRecomputedDerived(row, derived, cells) {
11
20
  if (derived === undefined)
12
21
  return row;
@@ -124,6 +133,12 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
124
133
  useQuery(source.queryQn, {
125
134
  limit: REFERENCE_COMBOBOX_LIMIT,
126
135
  }));
136
+ // The first options page can miss a stored value; without its row the cell shows no label.
137
+ const storedReferenceQueries = referenceCells.map((cell, idx) => {
138
+ const storedIds = storedCellIds(rows, cell.field);
139
+ // biome-ignore lint/correctness/useHookAtTopLevel: same fixed per-mount hook count as referenceQueries above.
140
+ return useQuery(referenceSources[idx]?.queryQn ?? "", storedReferenceRowsPayload(cell, storedIds, REFERENCE_COMBOBOX_LIMIT), { enabled: storedIds.length > 0 });
141
+ });
127
142
  if (EmbeddedListInput === undefined)
128
143
  return null;
129
144
  const columns = cells.map((cell) => {
@@ -132,7 +147,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
132
147
  const idx = referenceCells.indexOf(cell);
133
148
  const query = referenceQueries[idx];
134
149
  const labelKey = referenceSources[idx]?.labelKey ?? "id";
135
- const referenceOptions = (query?.data?.rows ?? []).map((row) => ({
150
+ const referenceOptions = withUnlistedSelectedRows(query?.data?.rows ?? [], storedReferenceQueries[idx]?.data?.rows ?? []).map((row) => ({
136
151
  value: String(row["id"] ?? ""),
137
152
  label: String(row[labelKey] ?? row["id"] ?? ""),
138
153
  }));
@@ -5,11 +5,11 @@ import { useReferenceLinkResolver } from "../app/reference-link.js";
5
5
  import { EMPTY_VALUE_PLACEHOLDER } from "../headless/index.js";
6
6
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
7
7
  import { useQuery } from "../hooks/use-query.js";
8
- import { isCompleteRowPage, referenceOptionSource } from "../hooks/use-reference-lookup.js";
8
+ import { isCompleteRowPage, referenceOptionSource, referenceRowsByIdsPayload, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
9
9
  import { useTranslation } from "../i18n.js";
10
10
  import { usePrimitives } from "../primitives.js";
11
11
  import { ReferenceCreateDialog } from "./reference-create-dialog.js";
12
- import { stringArrayOf } from "./render-field-values.js";
12
+ import { selectedReferenceIds, stringArrayOf } from "./render-field-values.js";
13
13
  import { useReferenceInputState } from "./use-reference-input-state.js";
14
14
  // ReferenceInput renders a searchable combobox populated by a live query for
15
15
  // the referenced entity. With the default limit of 200, larger datasets miss
@@ -64,19 +64,17 @@ export function ReadOnlyReferenceValue({ field, featureName, }) {
64
64
  const resolveReferenceLink = useReferenceLinkResolver();
65
65
  const refEntity = field.refEntity ?? "";
66
66
  const refFeature = field.refFeature ?? featureName;
67
- const isMultiple = field.refMultiple === true;
68
67
  const { queryQn, labelKey } = referenceOptionSource(field, refFeature, refEntity);
69
- const queryResult = useQuery(queryQn, {
70
- limit: REFERENCE_COMBOBOX_LIMIT,
71
- });
72
- const ids = isMultiple
73
- ? stringArrayOf(field.value)
74
- : typeof field.value === "string" && field.value !== ""
75
- ? [field.value]
76
- : [];
68
+ const ids = selectedReferenceIds(field);
69
+ // An author `refOptionsQuery` keeps its capped picker page; a strict handler schema
70
+ // that rejects `ids` then only fails the extra stored-rows query.
71
+ const queryResult = useQuery(queryQn, field.refOptionsQuery === undefined
72
+ ? referenceRowsByIdsPayload(ids, REFERENCE_COMBOBOX_LIMIT)
73
+ : { limit: REFERENCE_COMBOBOX_LIMIT }, { enabled: ids.length > 0 });
74
+ const storedResult = useQuery(queryQn, storedReferenceRowsPayload(field, ids, REFERENCE_COMBOBOX_LIMIT), { enabled: field.refOptionsQuery !== undefined && ids.length > 0 });
77
75
  if (ids.length === 0)
78
76
  return _jsx(Text, { testId: `field-value-${field.field}`, children: EMPTY_VALUE_PLACEHOLDER });
79
- const rows = queryResult.data?.rows ?? [];
77
+ const rows = withUnlistedSelectedRows(queryResult.data?.rows ?? [], storedResult.data?.rows ?? []);
80
78
  // System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row —
81
79
  // same central fallback as useReferenceLookup (render-list.tsx), so a
82
80
  // reference field showing one on the detail path also gets a label
@@ -8,6 +8,10 @@ export declare function resolveNumberUnit(unit: string | {
8
8
  } | undefined, row: Readonly<Record<string, unknown>> | undefined): string | undefined;
9
9
  export declare function resolveAffix(affix: FieldAffix | undefined, row: Readonly<Record<string, unknown>> | undefined): string | undefined;
10
10
  export declare function stringArrayOf(value: unknown): readonly string[];
11
+ export declare function selectedReferenceIds(field: {
12
+ readonly value: unknown;
13
+ readonly refMultiple?: boolean;
14
+ }): readonly string[];
11
15
  export declare function withPinnedValues(next: readonly string[], options: readonly SelectOptionEntry[], current: readonly string[]): readonly string[];
12
16
  export declare function moneyMinorValue(v: unknown, currency: string): number | "";
13
17
  export declare function moneyPayload(minorUnits: number | undefined, currency: string): unknown;
@@ -67,6 +67,13 @@ export function stringArrayOf(value) {
67
67
  ? value.filter((item) => typeof item === "string")
68
68
  : [];
69
69
  }
70
+ // The ids a reference field currently points at: the array of a `refMultiple`
71
+ // field, else the single non-empty id.
72
+ export function selectedReferenceIds(field) {
73
+ if (field.refMultiple === true)
74
+ return stringArrayOf(field.value);
75
+ return typeof field.value === "string" && field.value !== "" ? [field.value] : [];
76
+ }
70
77
  // The combobox cannot disable a single option, so removals of disabled options
71
78
  // the form already holds are merged back.
72
79
  export function withPinnedValues(next, options, current) {
@@ -1,3 +1,4 @@
1
1
  import type { ReactNode } from "react";
2
- import type { PublishedReferenceLookup, ReferenceColumnSpec } from "./render-list-reference-columns.ts";
3
- export declare function renderReferenceLookupBridges(referenceColumns: readonly ReferenceColumnSpec[], onMap: (field: string, lookup: PublishedReferenceLookup) => void): ReactNode;
2
+ import { type PublishedReferenceChunk, type ReferenceLookupRequest } from "./render-list-reference-columns.ts";
3
+ export type ReferenceChunkPublisher = (field: string, chunkIndex: number, lookup: PublishedReferenceChunk) => void;
4
+ export declare function renderReferenceLookupBridges(referenceRequests: readonly ReferenceLookupRequest[], onMap: ReferenceChunkPublisher): ReactNode;
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect } from "react";
3
3
  import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
4
+ import { referenceChunkKey, } from "./render-list-reference-columns.js";
4
5
  // Bridge component for each reference column. It is mounted once per
5
6
  // column, calls useReferenceLookup unconditionally, and publishes the map
6
7
  // to the parent through onMap. React manages mounting and unmounting when
7
8
  // the schema changes through the key prop in the parent's map loop.
8
- function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMap, }) {
9
- const lookup = useReferenceLookup(featureName, refEntity, labelField);
9
+ function ReferenceLookupBridge({ field, chunkIndex, ids, refEntity, labelField, featureName, onMap, }) {
10
+ const lookup = useReferenceLookup(featureName, refEntity, labelField, ids);
10
11
  // Use an effect rather than calling directly so setState runs outside the
11
12
  // render path; otherwise React warns about updating one component while
12
13
  // rendering another.
@@ -14,12 +15,15 @@ function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMa
14
15
  // "not resolved", whereas an empty published map means "nothing found".
15
16
  // Only the latter may display "Unknown".
16
17
  const resolved = !lookup.loading && !lookup.failed;
18
+ const idsKey = referenceChunkKey(ids);
19
+ // The parent keys this bridge by its ids, so a changed id set remounts it and
20
+ // useQuery starts loading instead of handing back the previous ids' rows.
17
21
  useEffect(() => {
18
22
  if (resolved)
19
- onMap(field, { map: lookup.map, complete: lookup.complete });
20
- }, [field, lookup.map, lookup.complete, resolved, onMap]);
23
+ onMap(field, chunkIndex, { map: lookup.map, complete: lookup.complete, idsKey });
24
+ }, [field, chunkIndex, idsKey, lookup.map, lookup.complete, resolved, onMap]);
21
25
  return null;
22
26
  }
23
- export function renderReferenceLookupBridges(referenceColumns, onMap) {
24
- return referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: onMap }, rc.field)));
27
+ export function renderReferenceLookupBridges(referenceRequests, onMap) {
28
+ return referenceRequests.flatMap((request) => request.idChunks.map((ids, chunkIndex) => (_jsx(ReferenceLookupBridge, { field: request.field, chunkIndex: chunkIndex, ids: ids, refEntity: request.refEntity, labelField: request.labelField, featureName: request.refFeature, onMap: onMap }, `${request.field}:${referenceChunkKey(ids)}`))));
25
29
  }
@@ -4,6 +4,8 @@ import type { ReferenceLookupMap } from "../hooks/use-reference-lookup.ts";
4
4
  export type PublishedReferenceLookup = {
5
5
  readonly map: ReferenceLookupMap;
6
6
  readonly complete: boolean;
7
+ /** The map is from an earlier id set; the current ids are still loading. */
8
+ readonly pending?: true;
7
9
  };
8
10
  export type ReferenceColumnSpec = {
9
11
  readonly field: string;
@@ -11,6 +13,15 @@ export type ReferenceColumnSpec = {
11
13
  readonly refFeature: string;
12
14
  readonly labelField: string;
13
15
  };
16
+ export type ReferenceLookupRequest = ReferenceColumnSpec & {
17
+ readonly idChunks: readonly (readonly string[])[];
18
+ };
19
+ export declare function referenceIdChunks(rows: readonly Readonly<Record<string, unknown>>[], field: string, chunkSize: number): string[][];
20
+ export type PublishedReferenceChunk = PublishedReferenceLookup & {
21
+ readonly idsKey: string;
22
+ };
23
+ export declare function referenceChunkKey(ids: readonly string[]): string;
24
+ export declare function mergeChunkLookups(idChunks: readonly (readonly string[])[], published: Readonly<Record<number, PublishedReferenceChunk>> | undefined): PublishedReferenceLookup | undefined;
14
25
  type ResolveReferenceLink = ReturnType<typeof useReferenceLinkResolver>;
15
26
  export declare function selectReferenceColumns(columns: readonly ListColumnViewModel[], featureName: string): ReferenceColumnSpec[];
16
27
  export type EnrichReferenceColumnsInput = {
@@ -1,5 +1,47 @@
1
1
  import { UNRESOLVED_REFERENCE_LABEL_KEYS } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { EMPTY_VALUE_PLACEHOLDER } from "../headless/index.js";
3
+ // Chunks in first-seen order, so appending an infinite-scroll page only
4
+ // re-queries the last chunk; the chunk size stays within the list handler's limit.
5
+ export function referenceIdChunks(rows, field, chunkSize) {
6
+ const ids = new Set();
7
+ for (const row of rows) {
8
+ const value = row[field];
9
+ for (const entry of Array.isArray(value) ? value : [value]) {
10
+ if (typeof entry === "string" && entry !== "")
11
+ ids.add(entry);
12
+ }
13
+ }
14
+ const ordered = [...ids];
15
+ const chunks = [];
16
+ for (let start = 0; start < ordered.length; start += chunkSize) {
17
+ chunks.push(ordered.slice(start, start + chunkSize));
18
+ }
19
+ return chunks;
20
+ }
21
+ export function referenceChunkKey(ids) {
22
+ return ids.join("\n");
23
+ }
24
+ // A chunk last resolved for other ids keeps its labels on screen, but it is
25
+ // `pending` and cannot prove absence until the current ids have loaded.
26
+ export function mergeChunkLookups(idChunks, published) {
27
+ const chunks = idChunks.map((ids, chunkIndex) => {
28
+ const chunk = published?.[chunkIndex];
29
+ return { chunk, current: chunk?.idsKey === referenceChunkKey(ids) };
30
+ });
31
+ const resolved = chunks.flatMap(({ chunk }) => (chunk === undefined ? [] : [chunk]));
32
+ if (resolved.length === 0)
33
+ return undefined;
34
+ const pending = !chunks.every(({ current }) => current);
35
+ const complete = !pending && resolved.every((chunk) => chunk.complete);
36
+ const only = resolved.length === 1 && chunks.length === 1 ? resolved[0] : undefined;
37
+ if (only !== undefined && !pending)
38
+ return only;
39
+ const map = new Map();
40
+ for (const chunk of resolved)
41
+ for (const [id, label] of chunk.map)
42
+ map.set(id, label);
43
+ return { map, complete, ...(pending && { pending }) };
44
+ }
3
45
  function isPlainRecord(value) {
4
46
  return typeof value === "object" && value !== null && !Array.isArray(value);
5
47
  }
@@ -32,7 +74,7 @@ export function selectReferenceColumns(columns, featureName) {
32
74
  function unresolvedLabelFor(unknownKey, published, translate) {
33
75
  return (id) => unknownKey === undefined
34
76
  ? id
35
- : published === undefined
77
+ : published === undefined || published.pending === true
36
78
  ? EMPTY_VALUE_PLACEHOLDER
37
79
  : published.complete
38
80
  ? translate(unknownKey)
@@ -24,7 +24,7 @@ export function RenderList(props) {
24
24
  const [menuActionBusyId, setMenuActionBusyId] = useState(null);
25
25
  const [menuActionError, setMenuActionError] = useState(null);
26
26
  const { localQ, setLocalQ } = useRenderListSearch(searchValue, onSearchChange);
27
- const { referenceColumns, handleLookupMap, enrichedVm } = useRenderListColumns({
27
+ const { referenceRequests, handleLookupMap, enrichedVm } = useRenderListColumns({
28
28
  screen,
29
29
  entity,
30
30
  rows,
@@ -93,7 +93,7 @@ export function RenderList(props) {
93
93
  const toolbarTitle = resolveToolbarTitle(screen, translate);
94
94
  return (_jsxs(_Fragment, { children: [headerActions !== undefined &&
95
95
  PageHeader !== undefined &&
96
- renderPageHeader(PageHeader, headerActions, split.promotedInOverflowMenu, promotedMenuItems), menuActionError !== null && renderMenuActionError(Banner, menuActionError), renderReferenceLookupBridges(referenceColumns, handleLookupMap), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...tableChromeProps({
96
+ renderPageHeader(PageHeader, headerActions, split.promotedInOverflowMenu, promotedMenuItems), menuActionError !== null && renderMenuActionError(Banner, menuActionError), renderReferenceLookupBridges(referenceRequests, handleLookupMap), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...tableChromeProps({
97
97
  onRowClick,
98
98
  composedEmptyState,
99
99
  toolbarStart,
@@ -5,8 +5,8 @@ import { useUserRoles } from "../context/user-roles-context.js";
5
5
  import { rendererError } from "../dev-console.js";
6
6
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
7
7
  import { useQuery } from "../hooks/use-query.js";
8
- import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
9
- import { stringArrayOf } from "./render-field-values.js";
8
+ import { referenceOptionSource, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
9
+ import { selectedReferenceIds, stringArrayOf } from "./render-field-values.js";
10
10
  export function useReferenceInputState(field, featureName, onChange) {
11
11
  const refEntity = field.refEntity ?? "";
12
12
  const refFeature = field.refFeature ?? featureName;
@@ -41,6 +41,9 @@ export function useReferenceInputState(field, featureName, onChange) {
41
41
  ? { limit: REFERENCE_COMBOBOX_LIMIT }
42
42
  : { limit: REFERENCE_COMBOBOX_LIMIT, search: searchTerm }, [searchTerm]);
43
43
  const queryResult = useQuery(queryQn, queryPayload);
44
+ // The first page can miss the stored value; without its row the combobox shows no label.
45
+ const selectedIds = selectedReferenceIds(field);
46
+ const selectedResult = useQuery(queryQn, storedReferenceRowsPayload(field, selectedIds, REFERENCE_COMBOBOX_LIMIT), { enabled: selectedIds.length > 0 });
44
47
  const handleSearchChange = useCallback((q) => setSearchTerm(q), []);
45
48
  const createTarget = !field.readOnly && refCreateScreen !== undefined && refEntityDef !== undefined
46
49
  ? { screen: refCreateScreen, entity: refEntityDef }
@@ -63,13 +66,13 @@ export function useReferenceInputState(field, featureName, onChange) {
63
66
  }
64
67
  }, [isMultiple, field.value, onChange, queryResult.refetch]);
65
68
  const options = useMemo(() => {
66
- const rows = queryResult.data?.rows ?? [];
69
+ const rows = withUnlistedSelectedRows(queryResult.data?.rows ?? [], selectedResult.data?.rows ?? []);
67
70
  return rows.map((row) => {
68
71
  const idVal = String(row["id"] ?? "");
69
72
  const label = String(row[labelKey] ?? idVal);
70
73
  return { value: idVal, label };
71
74
  });
72
- }, [queryResult.data, labelKey]);
75
+ }, [queryResult.data, selectedResult.data, labelKey]);
73
76
  return {
74
77
  refFeature,
75
78
  isMultiple,
@@ -1,7 +1,7 @@
1
1
  import type { EntityDefinition, EntityListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { type ScreenLinkResolver } from "../app/reference-link.ts";
3
3
  import type { ListColumnViewModel, ListViewModel, Translate } from "../headless/index.ts";
4
- import { type PublishedReferenceLookup } from "./render-list-reference-columns.ts";
4
+ import { type PublishedReferenceChunk, type ReferenceLookupRequest } from "./render-list-reference-columns.ts";
5
5
  export declare function withScreenLinks(columns: readonly ListColumnViewModel[], screen: EntityListScreenDefinition, resolveScreenLink: ScreenLinkResolver): readonly ListColumnViewModel[];
6
6
  type RenderListColumnsInput = {
7
7
  readonly screen: EntityListScreenDefinition;
@@ -11,8 +11,8 @@ type RenderListColumnsInput = {
11
11
  readonly translate: Translate;
12
12
  };
13
13
  export declare function useRenderListColumns(input: RenderListColumnsInput): {
14
- referenceColumns: import("./render-list-reference-columns.ts").ReferenceColumnSpec[];
15
- handleLookupMap: (field: string, lookup: PublishedReferenceLookup) => void;
14
+ referenceRequests: readonly ReferenceLookupRequest[];
15
+ handleLookupMap: (field: string, chunkIndex: number, lookup: PublishedReferenceChunk) => void;
16
16
  enrichedVm: Omit<ListViewModel, "columns"> & {
17
17
  columns: readonly ListColumnViewModel[];
18
18
  };
@@ -2,7 +2,8 @@ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { useCallback, useMemo, useState } from "react";
3
3
  import { useReferenceLinkResolver, useScreenLinkResolver, } from "../app/reference-link.js";
4
4
  import { computeListViewModel } from "../headless/index.js";
5
- import { enrichReferenceColumns, selectReferenceColumns, } from "./render-list-reference-columns.js";
5
+ import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
6
+ import { enrichReferenceColumns, mergeChunkLookups, referenceIdChunks, selectReferenceColumns, } from "./render-list-reference-columns.js";
6
7
  // A column whose rows name their own target screen links each cell there.
7
8
  export function withScreenLinks(columns, screen, resolveScreenLink) {
8
9
  const linkFields = new Map();
@@ -37,18 +38,34 @@ export function useRenderListColumns(input) {
37
38
  // through onMap. Each bridge makes a constant number of hook calls (one),
38
39
  // and React manages its lifecycle when the schema changes via key.
39
40
  const referenceColumns = useMemo(() => selectReferenceColumns(vm.columns, featureName), [vm.columns, featureName]);
40
- const [referenceLookups, setReferenceLookups] = useState({});
41
- const handleLookupMap = useCallback((field, lookup) => {
42
- setReferenceLookups((prev) => {
41
+ const referenceRequests = useMemo(() => referenceColumns.map((column) => ({
42
+ ...column,
43
+ idChunks: referenceIdChunks(rows, column.field, REFERENCE_LIST_LOOKUP_LIMIT),
44
+ })), [referenceColumns, rows]);
45
+ const [chunkLookups, setChunkLookups] = useState({});
46
+ const handleLookupMap = useCallback((field, chunkIndex, lookup) => {
47
+ setChunkLookups((prev) => {
43
48
  // Skip the update when the lookup is identical. Otherwise the bridge
44
49
  // would re-render and send the same map back until React entered
45
50
  // an infinite loop.
46
- const current = prev[field];
47
- if (current?.map === lookup.map && current.complete === lookup.complete)
51
+ const current = prev[field]?.[chunkIndex];
52
+ const unchanged = current?.map === lookup.map &&
53
+ current.complete === lookup.complete &&
54
+ current.idsKey === lookup.idsKey;
55
+ if (unchanged)
48
56
  return prev;
49
- return { ...prev, [field]: lookup };
57
+ return { ...prev, [field]: { ...prev[field], [chunkIndex]: lookup } };
50
58
  });
51
59
  }, []);
60
+ const referenceLookups = useMemo(() => {
61
+ const merged = {};
62
+ for (const request of referenceRequests) {
63
+ const lookup = mergeChunkLookups(request.idChunks, chunkLookups[request.field]);
64
+ if (lookup !== undefined)
65
+ merged[request.field] = lookup;
66
+ }
67
+ return merged;
68
+ }, [referenceRequests, chunkLookups]);
52
69
  const resolveReferenceLink = useReferenceLinkResolver();
53
70
  const resolveScreenLink = useScreenLinkResolver();
54
71
  const enrichedColumns = useMemo(() => withScreenLinks(enrichReferenceColumns({
@@ -72,5 +89,5 @@ export function useRenderListColumns(input) {
72
89
  ...vm,
73
90
  columns: enrichedColumns,
74
91
  }), [vm, enrichedColumns]);
75
- return { referenceColumns, handleLookupMap, enrichedVm };
92
+ return { referenceRequests, handleLookupMap, enrichedVm };
76
93
  }
@@ -13,18 +13,27 @@ export declare function referenceOptionSource(field: {
13
13
  readonly queryQn: string;
14
14
  readonly labelKey: string;
15
15
  };
16
+ export declare function referenceRowsByIdsPayload(ids: readonly string[], capLimit: number): Record<string, unknown>;
17
+ type ReferenceRow = Readonly<Record<string, unknown>>;
18
+ export declare function withUnlistedSelectedRows(listedRows: readonly ReferenceRow[], selectedRows: readonly ReferenceRow[]): readonly ReferenceRow[];
19
+ export declare function storedReferenceRowsPayload(field: {
20
+ readonly refOptionsQuery?: string;
21
+ }, ids: readonly string[], capLimit: number): Record<string, unknown>;
16
22
  export declare function isCompleteRowPage(data: {
17
23
  readonly nextCursor?: string | null;
18
24
  } | null): boolean;
19
- /** `map` holds the resolved labels; while the query loads or has failed an id is
25
+ /** Without `ids` the lookup reads the first capped page, as an option source does;
26
+ * with `ids` it asks for exactly those rows.
27
+ * `map` holds the resolved labels; while the query loads or has failed an id is
20
28
  * simply not in it. Callers decide the fallback: the raw id, or the "Unknown"
21
29
  * label for entities listed in `UNRESOLVED_REFERENCE_LABEL_KEYS` once the
22
30
  * lookup is complete.
23
31
  * `featureName` is the reference's target feature, not the screen's. */
24
- export declare function useReferenceLookup(featureName: string, refEntity: string, labelField: string): {
32
+ export declare function useReferenceLookup(featureName: string, refEntity: string, labelField: string, ids?: readonly string[]): {
25
33
  readonly map: ReferenceLookupMap;
26
34
  readonly loading: boolean;
27
35
  readonly failed: boolean;
28
36
  /** False when the lookup page was capped: absent ids may still exist. */
29
37
  readonly complete: boolean;
30
38
  };
39
+ export {};
@@ -1,21 +1,16 @@
1
1
  // Renderer-side eager load for reference fields.
2
2
  //
3
3
  // Each reference column of a list calls `<feature>:query:
4
- // <refEntity>:list` (limit:200) once, builds a Map<uuid, displayValue>,
5
- // and the renderer uses it as the cell display renderer.
4
+ // <refEntity>:list` for the ids on screen (in chunks of 200), builds a
5
+ // Map<uuid, displayValue>, and the renderer uses it as the cell display renderer.
6
6
  //
7
7
  // Strategy trade-offs:
8
8
  // - Server-side Drizzle joins would be more efficient (one query instead
9
9
  // of N+1) but would have to extend the executor API with lookupTables.
10
10
  // The renderer-side path wins for the MVP.
11
- // - limit:200 is a hard UX limit: with more entries in the referenced
12
- // entity, the last rows fall back to the raw id (or "Unknown" for
13
- // opt-in entities once the lookup is complete). A searchable combobox plus a
14
- // server-side eager load with an ID whitelist will lift that later.
15
- //
16
- // The hook is call-stable: useQuery's internal cache shares the lookup map
17
- // between list and edit form for the same entity, and live updates arrive
18
- // via SSE (use-query-live).
11
+ // - A source that ignores both by-id inputs still answers its first 200 rows,
12
+ // so later ids fall back to the raw id (or "Unknown" for opt-in entities
13
+ // once the lookup is complete).
19
14
  import { REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "@cosmicdrift/kumiko-framework/ui-types";
20
15
  import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-types/kms-adapter-types";
21
16
  import { useMemo } from "react";
@@ -45,21 +40,48 @@ export function referenceOptionSource(field, refFeature, refEntity) {
45
40
  }
46
41
  return referenceLookupSource(refFeature, refEntity, field.refLabelField ?? "id");
47
42
  }
43
+ // Asks a label source for exactly these rows, so a stored id cannot fall beyond a
44
+ // lookup cap: `ids` is the by-id input of directory sources, `filters` that of entity
45
+ // list handlers. Both only narrow the caller's own read scope. A hand-written handler
46
+ // that knows neither still answers its first `capLimit` rows, as before.
47
+ export function referenceRowsByIdsPayload(ids, capLimit) {
48
+ return {
49
+ limit: Math.max(ids.length, capLimit),
50
+ ids,
51
+ filters: [{ field: "id", op: "in", value: ids }],
52
+ };
53
+ }
54
+ // Option rows of a picker plus the stored ones its first page did not carry, so a
55
+ // stored value keeps its label without dropping the picker's own order.
56
+ export function withUnlistedSelectedRows(listedRows, selectedRows) {
57
+ const listedIds = new Set(listedRows.map((row) => String(row["id"] ?? "")));
58
+ const unlisted = selectedRows.filter((row) => !listedIds.has(String(row["id"] ?? "")));
59
+ return unlisted.length === 0 ? listedRows : [...listedRows, ...unlisted];
60
+ }
61
+ // An author `refOptionsQuery` takes `{ limit, search? }` for its picker page and `ids`
62
+ // for stored values; it knows no list filters.
63
+ export function storedReferenceRowsPayload(field, ids, capLimit) {
64
+ return field.refOptionsQuery === undefined
65
+ ? referenceRowsByIdsPayload(ids, capLimit)
66
+ : { limit: Math.max(ids.length, capLimit), ids };
67
+ }
48
68
  // A page with a continuation cursor is only a prefix of the referenced rows,
49
69
  // so a missing id in it proves nothing about the row's existence.
50
70
  export function isCompleteRowPage(data) {
51
71
  return data !== null && (data.nextCursor ?? null) === null;
52
72
  }
53
- /** `map` holds the resolved labels; while the query loads or has failed an id is
73
+ /** Without `ids` the lookup reads the first capped page, as an option source does;
74
+ * with `ids` it asks for exactly those rows.
75
+ * `map` holds the resolved labels; while the query loads or has failed an id is
54
76
  * simply not in it. Callers decide the fallback: the raw id, or the "Unknown"
55
77
  * label for entities listed in `UNRESOLVED_REFERENCE_LABEL_KEYS` once the
56
78
  * lookup is complete.
57
79
  * `featureName` is the reference's target feature, not the screen's. */
58
- export function useReferenceLookup(featureName, refEntity, labelField) {
80
+ export function useReferenceLookup(featureName, refEntity, labelField, ids) {
59
81
  const { queryQn, labelKey } = referenceLookupSource(featureName, refEntity, labelField);
60
- const result = useQuery(queryQn, {
61
- limit: REFERENCE_LIST_LOOKUP_LIMIT,
62
- });
82
+ const result = useQuery(queryQn, ids === undefined
83
+ ? { limit: REFERENCE_LIST_LOOKUP_LIMIT }
84
+ : referenceRowsByIdsPayload(ids, REFERENCE_LIST_LOOKUP_LIMIT), { enabled: ids === undefined || ids.length > 0 });
63
85
  const translate = useTranslation();
64
86
  const systemLabel = SYSTEM_REFERENCE_LABELS[`${featureName}:${refEntity}`];
65
87
  const map = useMemo(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.396.0",
3
+ "version": "0.398.0",
4
4
  "description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,8 +48,8 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-framework": "0.396.0",
52
- "@cosmicdrift/kumiko-types": "0.396.0",
51
+ "@cosmicdrift/kumiko-framework": "0.398.0",
52
+ "@cosmicdrift/kumiko-types": "0.398.0",
53
53
  "@types/qrcode": "^1.5.5",
54
54
  "class-variance-authority": "^0.7.1",
55
55
  "clsx": "^2.1.1",
@@ -67,8 +67,8 @@
67
67
  "zod": "^4.6.7"
68
68
  },
69
69
  "peerDependencies": {
70
- "@tailwindcss/cli": "^4.3.0",
71
- "tailwindcss": "^4.3.0"
70
+ "@tailwindcss/cli": "^4.3.3",
71
+ "tailwindcss": "^4.3.3"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@tailwindcss/cli": {
@@ -79,15 +79,15 @@
79
79
  }
80
80
  },
81
81
  "devDependencies": {
82
- "@cosmicdrift/kumiko-locale-de": "0.396.0",
83
- "@cosmicdrift/kumiko-testing": "0.396.0",
84
- "@tailwindcss/cli": "^4.3.0",
85
- "@testing-library/react": "^16.3.2",
86
- "@testing-library/user-event": "^14.6.1",
82
+ "@cosmicdrift/kumiko-locale-de": "0.398.0",
83
+ "@cosmicdrift/kumiko-testing": "0.398.0",
84
+ "@tailwindcss/cli": "^4.3.3",
85
+ "@testing-library/react": "^16.3.3",
86
+ "@testing-library/user-event": "^14.6.8",
87
87
  "@types/react": "^19.2.14",
88
88
  "@types/react-dom": "^19.2.3",
89
89
  "jsdom": "^29.1.1",
90
- "tailwindcss": "^4.3.0"
90
+ "tailwindcss": "^4.3.3"
91
91
  },
92
92
  "repository": {
93
93
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.398.0",
4
+ "type": "fix",
5
+ "title": "Reference labels resolve by id beyond the lookup caps",
6
+ "detail": "ReadOnlyReferenceValue, the reference combobox and embedded-list reference cells request the stored ids next to their first options page; list reference columns request the ids on screen in chunks of 200 instead of the first 200 rows of the target. The ids go out as `ids` for directory sources and an id-in filter for entity list handlers, which only narrow the caller's list query, so tenant, ownership and field-read rules keep applying. Author `optionsQuery` handlers on reference fields keep their capped `{ limit, search? }` picker page and are additionally asked for stored values with `{ limit, ids }`; a handler that ignores `ids` keeps the previous capped behaviour. List columns keep labels already known while the ids of a new page load and only claim \"Unknown\" once the current ids have resolved."
7
+ },
2
8
  {
3
9
  "version": "0.395.0",
4
10
  "type": "improvement",