@asteby/metacore-runtime-react 39.2.4 → 39.2.6

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/dist/validator.js CHANGED
@@ -259,3 +259,35 @@ function walk(fields, values, prefix, bag) {
259
259
  export function bagHasErrors(bag) {
260
260
  return Object.keys(bag).length > 0;
261
261
  }
262
+ /** Issue codes produced by a field's declarative rule (regex / min / max /
263
+ * custom builtins) — the ones the kernel grandfathers on update. */
264
+ const RULE_CODES = new Set(['regex', 'min', 'max', 'email', 'uuid', 'url', 'numeric', 'integer', 'custom']);
265
+ function sameValue(a, b) {
266
+ if (isEmpty(a) && isEmpty(b))
267
+ return true;
268
+ if (isNumeric(a) && isNumeric(b))
269
+ return numericValue(a) === numericValue(b);
270
+ return asString(a) === asString(b);
271
+ }
272
+ /**
273
+ * Edit mode: drop the rule issues (regex/min/max/custom) of a top-level field
274
+ * whose value is the one already persisted. The edit form posts every field
275
+ * back, so a rule added after the row was written (an RFC pattern over legacy
276
+ * data) would otherwise block saving a record over a field nobody touched.
277
+ * Mirrors the kernel (dynamic.validateWrite grandfathers unchanged values on
278
+ * update). required / invalid_option / invalid_type are kept.
279
+ */
280
+ export function exemptUnchangedRuleIssues(bag, values, persisted) {
281
+ if (!persisted)
282
+ return bag;
283
+ const out = {};
284
+ for (const [path, issues] of Object.entries(bag)) {
285
+ const unchanged = !path.includes('.') &&
286
+ Object.prototype.hasOwnProperty.call(persisted, path) &&
287
+ sameValue(values?.[path], persisted[path]);
288
+ const kept = unchanged ? issues.filter(i => !RULE_CODES.has(String(i.code))) : issues;
289
+ if (kept.length)
290
+ out[path] = kept;
291
+ }
292
+ return out;
293
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "39.2.4",
3
+ "version": "39.2.6",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "peerDependencies": {
29
29
  "@asteby/metacore-sdk": "^3.8.0",
30
- "@asteby/metacore-ui": "^2.18.1",
30
+ "@asteby/metacore-ui": "^2.18.2",
31
31
  "@tanstack/react-query": ">=5",
32
32
  "@tanstack/react-router": ">=1.100",
33
33
  "@tanstack/react-table": ">=8",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@asteby/metacore-sdk": "3.8.0",
54
- "@asteby/metacore-ui": "2.18.1",
54
+ "@asteby/metacore-ui": "2.18.2",
55
55
  "@tanstack/react-query": "^5.95.0",
56
56
  "@tanstack/react-router": "^1.168.0",
57
57
  "@tanstack/react-table": "^8.20.0",
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, it, expect, vi } from 'vitest'
2
- import { projectOption } from '../use-options-resolver'
2
+ import { optionsRequestKey, projectOption } from '../use-options-resolver'
3
3
  import {
4
4
  resolveValidatorToken,
5
5
  setOrgConfigBridge,
@@ -11,6 +11,14 @@ import {
11
11
  // hook performs) so consumers can rely on the v0.9.0 envelope reading
12
12
  // without spinning up a renderer.
13
13
 
14
+ describe('optionsRequestKey', () => {
15
+ it('keeps two orgs from sharing a column cache', () => {
16
+ const a = optionsRequestKey('org-a|branch', '/options/Product', 'id', '', 200, undefined)
17
+ const b = optionsRequestKey('org-b|branch', '/options/Product', 'id', '', 200, undefined)
18
+ expect(a).not.toBe(b)
19
+ })
20
+ })
21
+
14
22
  describe('projectOption', () => {
15
23
  it('mirrors id into value and label into name when missing', () => {
16
24
  const out = projectOption({ id: 'abc', label: 'Hello' })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseRuleString, checkValue, validateValues, bagHasErrors } from '../validator'
2
+ import { parseRuleString, checkValue, validateValues, bagHasErrors, exemptUnchangedRuleIssues } from '../validator'
3
3
  import type { ActionFieldDef } from '../types'
4
4
 
5
5
  describe('parseRuleString', () => {
@@ -68,3 +68,30 @@ describe('validateValues', () => {
68
68
  expect(bag.email?.[0]?.code).toBe('email')
69
69
  })
70
70
  })
71
+
72
+ describe('exemptUnchangedRuleIssues (QA LIVE-19)', () => {
73
+ const fields: ActionFieldDef[] = [
74
+ { key: 'name', label: 'Nombre', type: 'text', required: true } as ActionFieldDef,
75
+ { key: 'tax_id', label: 'RFC', type: 'text', validation: { regex: '^[A-Z]{3,4}[0-9]{6}[A-Z0-9]{3}$' } } as ActionFieldDef,
76
+ { key: 'price', label: 'Precio', type: 'number', validation: { min: 0 } } as ActionFieldDef,
77
+ ]
78
+ it('keeps an untouched legacy value from blocking the edit', () => {
79
+ const values = { name: 'Renombrado', tax_id: 'abc-010101', price: '-5' }
80
+ const bag = validateValues(fields, values)
81
+ expect(bagHasErrors(bag)).toBe(true)
82
+ const out = exemptUnchangedRuleIssues(bag, values, { name: 'Viejo', tax_id: 'abc-010101', price: -5 })
83
+ expect(bagHasErrors(out)).toBe(false)
84
+ })
85
+ it('still reports a changed invalid value and required', () => {
86
+ const values = { name: '', tax_id: 'xyz', price: -6 }
87
+ const out = exemptUnchangedRuleIssues(validateValues(fields, values), values, { name: 'Viejo', tax_id: 'abc-010101', price: -5 })
88
+ expect(out.tax_id?.[0]?.code).toBe('regex')
89
+ expect(out.price?.[0]?.code).toBe('min')
90
+ expect(out.name?.[0]?.code).toBe('required')
91
+ })
92
+ it('is a no-op without a persisted record (create)', () => {
93
+ const values = { name: 'x', tax_id: 'abc' }
94
+ const bag = validateValues(fields, values)
95
+ expect(exemptUnchangedRuleIssues(bag, values, null)).toEqual(bag)
96
+ })
97
+ })
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import * as React from 'react'
14
- import * as icons from 'lucide-react'
14
+ import { Calendar, Check, ExternalLink, Mail, Minus } from 'lucide-react'
15
15
  import { es, enUS } from 'date-fns/locale'
16
16
  import {
17
17
  Badge,
@@ -231,7 +231,7 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
231
231
  className="inline-flex items-center gap-1 text-sm text-muted-foreground"
232
232
  title={formatted.title}
233
233
  >
234
- <icons.Calendar className="h-3 w-3 opacity-60" />
234
+ <Calendar className="h-3 w-3 opacity-60" />
235
235
  {formatted.display}
236
236
  </span>
237
237
  )
@@ -245,9 +245,9 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
245
245
  return (
246
246
  <span className="inline-flex items-center gap-1">
247
247
  {value ? (
248
- <icons.Check className="h-3.5 w-3.5 text-green-500" />
248
+ <Check className="h-3.5 w-3.5 text-green-500" />
249
249
  ) : (
250
- <icons.Minus className="h-3.5 w-3.5 text-muted-foreground" />
250
+ <Minus className="h-3.5 w-3.5 text-muted-foreground" />
251
251
  )}
252
252
  <span className="text-sm text-muted-foreground">{value ? 'Sí' : 'No'}</span>
253
253
  </span>
@@ -350,7 +350,7 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
350
350
  className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
351
351
  onClick={(e) => e.stopPropagation()}
352
352
  >
353
- <icons.ExternalLink className="h-3 w-3 shrink-0" />
353
+ <ExternalLink className="h-3 w-3 shrink-0" />
354
354
  <span className="truncate" style={{ maxWidth: 200 }}>{label}</span>
355
355
  </a>
356
356
  )
@@ -368,7 +368,7 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
368
368
  className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
369
369
  onClick={(e) => e.stopPropagation()}
370
370
  >
371
- <icons.Mail className="h-3 w-3 shrink-0" />
371
+ <Mail className="h-3 w-3 shrink-0" />
372
372
  <span className="truncate" style={{ maxWidth: 200 }}>{email}</span>
373
373
  </a>
374
374
  )
@@ -67,7 +67,7 @@ import { AssistInterview } from '../assist-interview'
67
67
  import { FieldCell } from '../field-grid'
68
68
  import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
69
69
  import { normalizeRefFieldsForSubmit } from './normalize-submit'
70
- import { validateValues, bagHasErrors } from '../validator'
70
+ import { validateValues, bagHasErrors, exemptUnchangedRuleIssues } from '../validator'
71
71
  import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
72
72
  import { IconPickerField } from '../icon-picker-field'
73
73
  import { humanizeToken } from '../dynamic-columns-helpers'
@@ -868,7 +868,17 @@ export function DynamicRecordDialog({
868
868
  // Laravel-style: collect every issue from the shared validator
869
869
  // (required + rule strings / min/max / email…) on visible fields only.
870
870
  const visible = filterVisibleFields(modalMeta.fields, mode, formValues)
871
- const bag = validateValues(visible as ActionFieldDef[], formValues)
871
+ let bag = validateValues(visible as ActionFieldDef[], formValues)
872
+ // Edit: a legacy value re-sent unchanged is not re-judged by a rule
873
+ // added after it was written (same grandfathering as the kernel).
874
+ if (mode === 'edit' && record) {
875
+ const persisted: Record<string, unknown> = {}
876
+ for (const f of visible) {
877
+ const v = resolvePath(record, f.key)
878
+ if (v !== undefined) persisted[f.key] = v
879
+ }
880
+ bag = exemptUnchangedRuleIssues(bag, formValues, persisted)
881
+ }
872
882
  if (bagHasErrors(bag)) {
873
883
  const labels: Record<string, string> = {}
874
884
  for (const f of visible) labels[f.key] = f.label
@@ -1077,6 +1087,13 @@ export function DynamicRecordDialog({
1077
1087
  blow the two columns past the dialog width. */}
1078
1088
  <form
1079
1089
  id={formId}
1090
+ // Stable Aby / guidance contract: unique id={formId}
1091
+ // keeps nested create footers from submitting the
1092
+ // parent, while data-aby-* lets tours find the
1093
+ // open form without a hardcoded id.
1094
+ data-aby-record-form=""
1095
+ data-aby-model={model}
1096
+ data-aby-mode={mode}
1080
1097
  onSubmit={handleSubmit}
1081
1098
  className="grid gap-y-4"
1082
1099
  >
@@ -1264,7 +1281,7 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
1264
1281
  const isEditReadonly = (mode === 'edit' && !!field.readonly) || !!locked
1265
1282
 
1266
1283
  return (
1267
- <div className="flex flex-col gap-1.5">
1284
+ <div className="flex flex-col gap-1.5" data-aby-field={field.key}>
1268
1285
  <Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
1269
1286
  {field.label}
1270
1287
  {field.required && mode !== 'view' && !isEditReadonly && (
@@ -16,8 +16,7 @@ import * as React from 'react'
16
16
  import { ColumnDef } from '@tanstack/react-table'
17
17
  import { format, type Locale } from 'date-fns'
18
18
  import { es, enUS } from 'date-fns/locale'
19
- import * as icons from 'lucide-react'
20
- import { MoreHorizontal } from 'lucide-react'
19
+ import { Calendar, Check, Copy, Mail, Minus, MoreHorizontal } from 'lucide-react'
21
20
  import {
22
21
  Avatar,
23
22
  AvatarFallback,
@@ -198,9 +197,9 @@ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLen
198
197
  title="Copiar"
199
198
  >
200
199
  {copied ? (
201
- <icons.Check className="h-3.5 w-3.5 text-green-500" />
200
+ <Check className="h-3.5 w-3.5 text-green-500" />
202
201
  ) : (
203
- <icons.Copy className="h-3.5 w-3.5" />
202
+ <Copy className="h-3.5 w-3.5" />
204
203
  )}
205
204
  </button>
206
205
  </div>
@@ -1082,7 +1081,7 @@ export function makeDefaultGetDynamicColumns(
1082
1081
  className="flex items-center gap-1.5 text-muted-foreground"
1083
1082
  title={formatted.title}
1084
1083
  >
1085
- <icons.Calendar className="h-3.5 w-3.5 opacity-70" />
1084
+ <Calendar className="h-3.5 w-3.5 opacity-70" />
1086
1085
  <span className="text-sm font-medium">
1087
1086
  {formatted.display}
1088
1087
  </span>
@@ -1174,7 +1173,7 @@ export function makeDefaultGetDynamicColumns(
1174
1173
  className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
1175
1174
  onClick={(e) => e.stopPropagation()}
1176
1175
  >
1177
- <icons.Mail className="h-3.5 w-3.5 shrink-0 opacity-70" />
1176
+ <Mail className="h-3.5 w-3.5 shrink-0 opacity-70" />
1178
1177
  <span className="truncate max-w-[260px]">{email}</span>
1179
1178
  </a>
1180
1179
  )
@@ -1337,9 +1336,9 @@ export function makeDefaultGetDynamicColumns(
1337
1336
  return (
1338
1337
  <span className="inline-flex items-center gap-1.5">
1339
1338
  {value ? (
1340
- <icons.Check className="h-4 w-4 text-green-500" />
1339
+ <Check className="h-4 w-4 text-green-500" />
1341
1340
  ) : (
1342
- <icons.Minus className="h-4 w-4 text-muted-foreground" />
1341
+ <Minus className="h-4 w-4 text-muted-foreground" />
1343
1342
  )}
1344
1343
  {showText && (
1345
1344
  <span className="text-sm text-muted-foreground">
@@ -1,18 +1,48 @@
1
- // Minimal DynamicIcon — resolves a lucide-react icon by name. Used across
2
- // action modals and the default row-action menus. Hosts that need custom
3
- // icon sets can override by shadowing this component via their own prop.
4
- import * as icons from 'lucide-react'
1
+ // DynamicIcon resolves a lucide glyph by name without pulling the full icon
2
+ // set into the shared chunk. Each glyph is its own dynamic import.
3
+ import { lazy, Suspense, useMemo, type ComponentType } from 'react'
4
+ import dynamicIconImports from 'lucide-react/dynamicIconImports'
5
5
 
6
6
  export interface DynamicIconProps {
7
7
  name: string
8
8
  className?: string
9
9
  }
10
10
 
11
+ type IconName = keyof typeof dynamicIconImports
12
+
13
+ function pascalToKebab(pascal: string): string {
14
+ return pascal
15
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
16
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
17
+ .toLowerCase()
18
+ }
19
+
20
+ function kebabToPascal(kebab: string): string {
21
+ return kebab
22
+ .split('-')
23
+ .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : ''))
24
+ .join('')
25
+ }
26
+
27
+ function loaderFor(pascal: string) {
28
+ const kebab = pascalToKebab(pascal) as IconName
29
+ return dynamicIconImports[kebab]
30
+ }
31
+
11
32
  export function DynamicIcon({ name, className }: DynamicIconProps) {
12
- const resolved = resolveLucideIconName(name) ?? name
13
- const Icon = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[resolved]
33
+ const resolved = resolveLucideIconName(name)
34
+ const Icon = useMemo(() => {
35
+ if (!resolved) return null
36
+ const loader = loaderFor(resolved)
37
+ if (!loader) return null
38
+ return lazy(loader as () => Promise<{ default: ComponentType<{ className?: string }> }>)
39
+ }, [resolved])
14
40
  if (!Icon) return null
15
- return <Icon className={className} />
41
+ return (
42
+ <Suspense fallback={null}>
43
+ <Icon className={className} />
44
+ </Suspense>
45
+ )
16
46
  }
17
47
 
18
48
  // resolveLucideIconName — canonical PascalCase lucide name for a value that is
@@ -24,13 +54,10 @@ export function resolveLucideIconName(value: unknown): string | null {
24
54
  if (/[/\\.:\s]/.test(value)) return null
25
55
  let name = value
26
56
  if (/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
27
- name = value
28
- .split('-')
29
- .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
30
- .join('')
57
+ name = kebabToPascal(value)
31
58
  }
32
59
  if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) return null
33
- return (icons as unknown as Record<string, unknown>)[name] ? name : null
60
+ return loaderFor(name) ? name : null
34
61
  }
35
62
 
36
63
  // isLucideIconName — true when a string is a lucide-react icon name
@@ -43,3 +70,8 @@ export function resolveLucideIconName(value: unknown): string | null {
43
70
  export function isLucideIconName(value: unknown): value is string {
44
71
  return resolveLucideIconName(value) !== null
45
72
  }
73
+
74
+ /** PascalCase names of every lucide glyph, for the icon picker grid. */
75
+ export function lucideIconNames(): string[] {
76
+ return Object.keys(dynamicIconImports).map(kebabToPascal)
77
+ }
@@ -12,8 +12,8 @@
12
12
  // initial mode is inferred from the current value — path-like ("/" or ".")
13
13
  // means image, anything else means icon.
14
14
  import { useMemo, useState } from 'react'
15
- import { icons } from 'lucide-react'
16
15
  import { ChevronsUpDown } from 'lucide-react'
16
+ import { lucideIconNames } from './dynamic-icon'
17
17
  import {
18
18
  Button,
19
19
  Input,
@@ -35,7 +35,7 @@ export interface IconPickerFieldProps {
35
35
  * the DOM light: the lucide catalog is ~1500 glyphs, so we never mount them all. */
36
36
  const PAGE = 60
37
37
 
38
- const ALL_ICON_NAMES = Object.keys(icons)
38
+ const ALL_ICON_NAMES = lucideIconNames()
39
39
 
40
40
  /** True when a stored value looks like an image url/path rather than an icon name. */
41
41
  export function looksLikeImageValue(value: unknown): boolean {
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  checkValue,
39
39
  validateValues,
40
40
  bagHasErrors,
41
+ exemptUnchangedRuleIssues,
41
42
  type ValidationSpec,
42
43
  } from './validator'
43
44
  export { VALIDATION_CATALOGS, validationCatalog, validationMessageKey } from './validation-catalog'
@@ -100,11 +100,58 @@ export interface UseOptionsResolverResult {
100
100
  * `/api/options/:model?field=…` endpoint. Returns the v0.9.0 envelope
101
101
  * `{ data, meta: { type, count } }` projected into a stable shape.
102
102
  *
103
- * The hook is intentionally minimal: it does NOT debounce `query`
104
- * (callers should hold the controlled value and pass it post-debounce)
105
- * and does NOT cache across hook instances (apps that need shared state
106
- * compose this with TanStack Query in their own layer).
103
+ * The hook does NOT debounce `query` (callers should pass it post-debounce).
104
+ * Identical lookups share one in-flight request and a 30s cache, so a column
105
+ * of reference cells does not fan out one request per row. The key includes
106
+ * the org and branch from the host session.
107
107
  */
108
+ const OPTIONS_TTL_MS = 30_000
109
+
110
+ type OptionsPayload = { options: ResolvedOption[]; meta: OptionsMeta }
111
+
112
+ const optionsCache = new Map<string, { payload: OptionsPayload; at: number }>()
113
+ const optionsInflight = new Map<string, Promise<OptionsPayload>>()
114
+ const optionsEpoch = new Map<string, number>()
115
+
116
+ export function optionsRequestKey(
117
+ scope: string,
118
+ url: string,
119
+ field: string,
120
+ query: string,
121
+ limit: number | undefined,
122
+ filter: string | undefined,
123
+ ): string {
124
+ return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n')
125
+ }
126
+
127
+ function optionsScope(): string {
128
+ if (typeof localStorage === 'undefined') return ''
129
+ let org = ''
130
+ try {
131
+ const raw = localStorage.getItem('auth_user')
132
+ if (raw) org = String((JSON.parse(raw) as { organization_id?: string }).organization_id ?? '')
133
+ } catch {
134
+ org = ''
135
+ }
136
+ const branch = localStorage.getItem('active_branch_id') ?? ''
137
+ return `${org}|${branch}`
138
+ }
139
+
140
+ function readOptionsEnvelope(body: any): OptionsPayload {
141
+ const rawOptions: any[] = Array.isArray(body?.data) ? body.data : []
142
+ const metaPayload =
143
+ body?.meta && typeof body.meta === 'object'
144
+ ? body.meta
145
+ : { type: body?.type, count: rawOptions.length }
146
+ return {
147
+ options: rawOptions.map(projectOption),
148
+ meta: {
149
+ type: metaPayload?.type ?? 'dynamic',
150
+ count: typeof metaPayload?.count === 'number' ? metaPayload.count : rawOptions.length,
151
+ },
152
+ }
153
+ }
154
+
108
155
  export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult {
109
156
  const {
110
157
  modelKey,
@@ -146,9 +193,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
146
193
  return ''
147
194
  }, [fieldKey, ref])
148
195
 
149
- // Track the in-flight controller so a new fetch can abort the
150
- // previous one — matters for typeahead callers passing changing `query`.
151
- const abortRef = useRef<AbortController | null>(null)
196
+ const seenRefresh = useRef(0)
152
197
 
153
198
  useEffect(() => {
154
199
  if (!enabled || !url || !effectiveField) {
@@ -158,61 +203,75 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
158
203
  setError(null)
159
204
  return
160
205
  }
161
- // Cancel any pending request before issuing a new one.
162
- abortRef.current?.abort()
163
- const controller = new AbortController()
164
- abortRef.current = controller
206
+
207
+ const key = optionsRequestKey(
208
+ optionsScope(),
209
+ url,
210
+ effectiveField,
211
+ query ?? '',
212
+ limit,
213
+ filterValue,
214
+ )
215
+ if (refreshKey !== seenRefresh.current) {
216
+ seenRefresh.current = refreshKey
217
+ optionsEpoch.set(key, (optionsEpoch.get(key) ?? 0) + 1)
218
+ optionsCache.delete(key)
219
+ optionsInflight.delete(key)
220
+ }
221
+ const epoch = optionsEpoch.get(key) ?? 0
222
+ const cached = optionsCache.get(key)
223
+ if (cached && Date.now() - cached.at < OPTIONS_TTL_MS) {
224
+ setOptions(cached.payload.options)
225
+ setMeta(cached.payload.meta)
226
+ setLoading(false)
227
+ setError(null)
228
+ return
229
+ }
165
230
 
166
231
  setLoading(true)
167
232
  setError(null)
168
233
 
169
- const params: Record<string, string | number> = { field: effectiveField }
170
- if (query) params.q = query
171
- if (typeof limit === 'number' && limit > 0) params.limit = limit
172
- // Cascade scope: a dependent picker passes the value of the field it
173
- // `dependsOn`. Skip empty strings so a cleared parent omits the param
174
- // (no scope) rather than querying for the empty-string filter_value.
175
- if (filterValue) params.filter_value = filterValue
176
-
177
- api.get(url, { params, signal: controller.signal })
178
- .then((res) => {
179
- if (controller.signal.aborted) return
234
+ let pending = optionsInflight.get(key)
235
+ if (!pending) {
236
+ const params: Record<string, string | number> = { field: effectiveField }
237
+ if (query) params.q = query
238
+ if (typeof limit === 'number' && limit > 0) params.limit = limit
239
+ if (filterValue) params.filter_value = filterValue
240
+ pending = api.get(url, { params }).then((res) => {
180
241
  const body = (res as { data: any }).data
181
242
  if (!body || body.success !== true) {
182
243
  throw new Error(body?.message || 'options resolver: unsuccessful response')
183
244
  }
184
- const rawOptions: any[] = Array.isArray(body.data) ? body.data : []
185
- const projected = rawOptions.map(projectOption)
186
- setOptions(projected)
187
- // v0.9.0 envelope: meta.type / meta.count. We tolerate
188
- // older deployments that still emit a root-level `type`
189
- // by reading either spot — the projection prefers the
190
- // canonical location so the SDK guides apps to the new
191
- // shape without breaking grace-period upgrades.
192
- const metaPayload =
193
- body.meta && typeof body.meta === 'object'
194
- ? body.meta
195
- : { type: body.type, count: rawOptions.length }
196
- setMeta({
197
- type: metaPayload?.type ?? 'dynamic',
198
- count:
199
- typeof metaPayload?.count === 'number'
200
- ? metaPayload.count
201
- : rawOptions.length,
202
- })
245
+ const payload = readOptionsEnvelope(body)
246
+ if ((optionsEpoch.get(key) ?? 0) === epoch) {
247
+ optionsCache.set(key, { payload, at: Date.now() })
248
+ }
249
+ return payload
250
+ }).finally(() => {
251
+ if (optionsInflight.get(key) === pending) optionsInflight.delete(key)
252
+ })
253
+ optionsInflight.set(key, pending)
254
+ }
255
+
256
+ let cancelled = false
257
+ pending
258
+ .then((payload) => {
259
+ if (cancelled) return
260
+ setOptions(payload.options)
261
+ setMeta(payload.meta)
203
262
  })
204
263
  .catch((err: any) => {
205
- if (controller.signal.aborted) return
264
+ if (cancelled) return
206
265
  setError(err instanceof Error ? err : new Error(String(err)))
207
266
  setOptions([])
208
267
  setMeta(null)
209
268
  })
210
269
  .finally(() => {
211
- if (!controller.signal.aborted) setLoading(false)
270
+ if (!cancelled) setLoading(false)
212
271
  })
213
272
 
214
273
  return () => {
215
- controller.abort()
274
+ cancelled = true
216
275
  }
217
276
  }, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey])
218
277
 
package/src/validator.ts CHANGED
@@ -273,3 +273,39 @@ function walk(
273
273
  export function bagHasErrors(bag: Record<string, FieldIssue[]>): boolean {
274
274
  return Object.keys(bag).length > 0
275
275
  }
276
+
277
+ /** Issue codes produced by a field's declarative rule (regex / min / max /
278
+ * custom builtins) — the ones the kernel grandfathers on update. */
279
+ const RULE_CODES = new Set(['regex', 'min', 'max', 'email', 'uuid', 'url', 'numeric', 'integer', 'custom'])
280
+
281
+ function sameValue(a: unknown, b: unknown): boolean {
282
+ if (isEmpty(a) && isEmpty(b)) return true
283
+ if (isNumeric(a) && isNumeric(b)) return numericValue(a) === numericValue(b)
284
+ return asString(a) === asString(b)
285
+ }
286
+
287
+ /**
288
+ * Edit mode: drop the rule issues (regex/min/max/custom) of a top-level field
289
+ * whose value is the one already persisted. The edit form posts every field
290
+ * back, so a rule added after the row was written (an RFC pattern over legacy
291
+ * data) would otherwise block saving a record over a field nobody touched.
292
+ * Mirrors the kernel (dynamic.validateWrite grandfathers unchanged values on
293
+ * update). required / invalid_option / invalid_type are kept.
294
+ */
295
+ export function exemptUnchangedRuleIssues(
296
+ bag: Record<string, FieldIssue[]>,
297
+ values: Record<string, unknown>,
298
+ persisted: Record<string, unknown> | null | undefined,
299
+ ): Record<string, FieldIssue[]> {
300
+ if (!persisted) return bag
301
+ const out: Record<string, FieldIssue[]> = {}
302
+ for (const [path, issues] of Object.entries(bag)) {
303
+ const unchanged =
304
+ !path.includes('.') &&
305
+ Object.prototype.hasOwnProperty.call(persisted, path) &&
306
+ sameValue(values?.[path], persisted[path])
307
+ const kept = unchanged ? issues.filter(i => !RULE_CODES.has(String(i.code))) : issues
308
+ if (kept.length) out[path] = kept
309
+ }
310
+ return out
311
+ }