@svadmin/core 0.31.0 → 0.32.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.
@@ -1,13 +1,11 @@
1
- /* eslint-disable svelte/prefer-svelte-reactivity */
2
1
  import { useQueryClient } from '@tanstack/svelte-query';
3
2
  import { useParsed } from './useParsed.svelte';
4
3
  import { getAdminOptions } from './options.svelte';
5
- import { getDataProviderForResource, getResource, getLiveProvider } from './context.svelte';
4
+ import { captureAdminContext } from './context.svelte';
6
5
  import { createQuery, createMutation } from '@tanstack/svelte-query';
7
6
  import { notify } from './notification.svelte';
8
- import { t } from './i18n.svelte';
7
+ import { useTranslation } from './i18n.svelte';
9
8
  import { audit } from './audit';
10
- import { navigate, currentPath } from './router';
11
9
  import { HttpError, UndoError } from './types';
12
10
  import type { BaseRecord, MutationMode, KnownResources } from './types';
13
11
  import { checkError } from './hook-utils.svelte';
@@ -136,6 +134,13 @@ export interface UseFormReturn<
136
134
  readonly mutation: unknown;
137
135
  }
138
136
 
137
+ interface FormRecordIdentity {
138
+ generation: number;
139
+ resource: string;
140
+ action: 'create' | 'edit' | 'clone' | 'show';
141
+ id: string | number | undefined;
142
+ }
143
+
139
144
  // ─── Implementation ──────────────────────────────────────────────────
140
145
 
141
146
  export function useForm<
@@ -143,6 +148,8 @@ export function useForm<
143
148
  TData extends BaseRecord = BaseRecord,
144
149
  TError = HttpError,
145
150
  >(options: UseFormOptions<TVariables, TData, TError> = {} as UseFormOptions<TVariables, TData, TError>): UseFormReturn<TVariables, TData> {
151
+ const adminContext = captureAdminContext();
152
+ const i18n = useTranslation();
146
153
  const queryClient = useQueryClient();
147
154
  const parsed = useParsed();
148
155
  const adminOptions = getAdminOptions();
@@ -170,16 +177,58 @@ export function useForm<
170
177
  // updates when `action` changes at runtime (e.g., via `setAction`).
171
178
  const redirectDefault = $derived(redirectOption ?? defaultRedirectOpt ?? 'list');
172
179
 
173
- const provider = $derived(getDataProviderForResource(resource, dataProviderName));
180
+ const provider = $derived(adminContext.getDataProviderForResource(resource, dataProviderName));
174
181
  const queryMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookQueryMeta });
175
182
  const mutationMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookMutationMeta });
176
183
 
177
- function setId(newId: string | number | undefined) { currentId = newId; }
178
- function setAction(newAction: 'create' | 'edit' | 'clone' | 'show') { action = newAction; }
184
+ let recordGeneration = 0;
185
+ let formDestroyed = false;
186
+
187
+ function captureRecordIdentity(): FormRecordIdentity {
188
+ return { generation: recordGeneration, resource, action, id: currentId };
189
+ }
190
+
191
+ function isRecordIdentityCurrent(identity: FormRecordIdentity): boolean {
192
+ return !formDestroyed
193
+ && identity.generation === recordGeneration
194
+ && identity.resource === resource
195
+ && identity.action === action;
196
+ }
197
+
198
+ function invalidateRecordIdentity() {
199
+ recordGeneration += 1;
200
+ if (autoSaveTimer) {
201
+ clearTimeout(autoSaveTimer);
202
+ autoSaveTimer = null;
203
+ }
204
+ if (autoSaveStatusTimer) {
205
+ clearTimeout(autoSaveStatusTimer);
206
+ autoSaveStatusTimer = null;
207
+ }
208
+ autoSaveDirty = false;
209
+ autoSaveStatus = 'idle';
210
+ lastAutoSaveData = null;
211
+ lastAutoSaveError = null;
212
+ tainted = {};
213
+ clearErrors();
214
+ }
215
+
216
+ function setId(newId: string | number | undefined) {
217
+ if (Object.is(currentId, newId)) return;
218
+ invalidateRecordIdentity();
219
+ currentId = newId;
220
+ }
221
+
222
+ function setAction(newAction: 'create' | 'edit' | 'clone' | 'show') {
223
+ if (action === newAction) return;
224
+ invalidateRecordIdentity();
225
+ action = newAction;
226
+ }
179
227
 
180
228
  // ─── Form values (single source of truth) ───────────────────────
181
- let values = $state<TVariables>((options.defaultValues ?? {}) as TVariables);
182
- let initialValues = $state<TVariables>((options.defaultValues ?? {}) as TVariables);
229
+ const defaultValuesSource = (options.defaultValues ?? {}) as TVariables;
230
+ let values = $state<TVariables>(createPersistenceSnapshot(defaultValuesSource));
231
+ let initialValues = createPersistenceSnapshot(defaultValuesSource);
183
232
 
184
233
  function setFieldValue(field: string, value: unknown, opts?: { taint?: boolean }) {
185
234
  values = { ...values, [field]: value } as TVariables;
@@ -188,6 +237,7 @@ export function useForm<
188
237
  // Clear field error on change
189
238
  if (errors[field]) clearFieldError(field);
190
239
  onChangeFn?.({ field, value, values });
240
+ triggerAutoSave();
191
241
  }
192
242
 
193
243
  function setValues(newValues: Partial<TVariables>, opts?: { taint?: boolean }) {
@@ -198,6 +248,7 @@ export function useForm<
198
248
  for (const key of Object.keys(newValues)) newTainted[key] = true;
199
249
  tainted = newTainted;
200
250
  }
251
+ triggerAutoSave();
201
252
  }
202
253
 
203
254
  // ─── Tainted (dirty) state ──────────────────────────────────────
@@ -208,6 +259,138 @@ export function useForm<
208
259
  return Object.values(tainted).some(Boolean);
209
260
  }
210
261
 
262
+ function isPlainObject(value: object): boolean {
263
+ const prototype = Object.getPrototypeOf(value);
264
+ return prototype === Object.prototype || prototype === null;
265
+ }
266
+
267
+ function isFormData(value: object): value is FormData {
268
+ return typeof FormData !== 'undefined' && value instanceof FormData;
269
+ }
270
+
271
+ function readOwnValue(value: object, key: PropertyKey): unknown {
272
+ const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
273
+ if (descriptor === undefined) return undefined;
274
+ return 'value' in descriptor ? descriptor.value : Reflect.get(value, key);
275
+ }
276
+
277
+ function snapshotPersistenceValue(value: unknown, seen: WeakMap<object, unknown>): unknown {
278
+ if (value === null || typeof value !== 'object') return value;
279
+
280
+ const existing = seen.get(value);
281
+ if (existing !== undefined) return existing;
282
+
283
+ if (value instanceof Date) {
284
+ const snapshot = new Date(value.getTime());
285
+ seen.set(value, snapshot);
286
+ return snapshot;
287
+ }
288
+
289
+ if (isFormData(value)) {
290
+ const snapshot = new FormData();
291
+ seen.set(value, snapshot);
292
+ for (const [key, entry] of value.entries()) snapshot.append(key, entry);
293
+ return snapshot;
294
+ }
295
+
296
+ if (Array.isArray(value)) {
297
+ const snapshot = new Array<unknown>(value.length);
298
+ seen.set(value, snapshot);
299
+ for (let index = 0; index < value.length; index += 1) {
300
+ if (index in value) snapshot[index] = snapshotPersistenceValue(readOwnValue(value, String(index)), seen);
301
+ }
302
+ return snapshot;
303
+ }
304
+
305
+ if (!isPlainObject(value)) return value;
306
+
307
+ const snapshot = Object.create(Object.getPrototypeOf(value)) as Record<PropertyKey, unknown>;
308
+ seen.set(value, snapshot);
309
+ for (const key of Reflect.ownKeys(value)) {
310
+ if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue;
311
+ snapshot[key] = snapshotPersistenceValue(readOwnValue(value, key), seen);
312
+ }
313
+ return snapshot;
314
+ }
315
+
316
+ function createPersistenceSnapshot(source: TVariables): TVariables {
317
+ return snapshotPersistenceValue(source, new WeakMap<object, unknown>()) as TVariables;
318
+ }
319
+
320
+ function enumerableOwnKeys(value: object): PropertyKey[] {
321
+ return Reflect.ownKeys(value).filter((key) => Object.prototype.propertyIsEnumerable.call(value, key));
322
+ }
323
+
324
+ function persistenceValuesEqual(
325
+ left: unknown,
326
+ right: unknown,
327
+ leftToRight: WeakMap<object, object> = new WeakMap<object, object>(),
328
+ rightToLeft: WeakMap<object, object> = new WeakMap<object, object>(),
329
+ ): boolean {
330
+ if (Object.is(left, right)) return true;
331
+ if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false;
332
+
333
+ const leftIsDate = left instanceof Date;
334
+ if (leftIsDate || right instanceof Date) {
335
+ return leftIsDate && right instanceof Date && Object.is(left.getTime(), right.getTime());
336
+ }
337
+
338
+ const leftIsFormData = isFormData(left);
339
+ if (leftIsFormData || isFormData(right)) {
340
+ if (!leftIsFormData || !isFormData(right)) return false;
341
+ const leftEntries = Array.from(left.entries());
342
+ const rightEntries = Array.from(right.entries());
343
+ if (leftEntries.length !== rightEntries.length) return false;
344
+ return leftEntries.every(([leftKey, leftValue], index) => {
345
+ const rightEntry = rightEntries[index];
346
+ return rightEntry !== undefined
347
+ && leftKey === rightEntry[0]
348
+ && persistenceValuesEqual(leftValue, rightEntry[1], leftToRight, rightToLeft);
349
+ });
350
+ }
351
+
352
+ const leftIsArray = Array.isArray(left);
353
+ if (leftIsArray !== Array.isArray(right)) return false;
354
+ if (!leftIsArray && (!isPlainObject(left) || !isPlainObject(right))) return false;
355
+ if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)) return false;
356
+
357
+ const previousRight = leftToRight.get(left);
358
+ if (previousRight !== undefined) return previousRight === right;
359
+ const previousLeft = rightToLeft.get(right);
360
+ if (previousLeft !== undefined) return previousLeft === left;
361
+ leftToRight.set(left, right);
362
+ rightToLeft.set(right, left);
363
+
364
+ if (leftIsArray && left.length !== (right as unknown[]).length) return false;
365
+
366
+ const leftKeys = enumerableOwnKeys(left);
367
+ const rightKeys = enumerableOwnKeys(right);
368
+ if (leftKeys.length !== rightKeys.length) return false;
369
+
370
+ for (const key of leftKeys) {
371
+ if (!Object.prototype.hasOwnProperty.call(right, key)) return false;
372
+ if (!persistenceValuesEqual(readOwnValue(left, key), readOwnValue(right, key), leftToRight, rightToLeft)) return false;
373
+ }
374
+ return true;
375
+ }
376
+
377
+ function reconcilePersistedSnapshot(sourceSnapshot: TVariables, persistedSnapshot: TVariables = sourceSnapshot) {
378
+ const nextInitialValues = createPersistenceSnapshot(persistedSnapshot);
379
+ let nextTainted = { ...tainted };
380
+
381
+ for (const field of Object.keys(sourceSnapshot)) {
382
+ if (persistenceValuesEqual(readOwnValue(values, field), readOwnValue(sourceSnapshot, field))) {
383
+ const { [field]: _persistedField, ...remainingTainted } = nextTainted;
384
+ nextTainted = remainingTainted;
385
+ } else {
386
+ nextTainted[field] = true;
387
+ }
388
+ }
389
+
390
+ initialValues = nextInitialValues;
391
+ tainted = nextTainted;
392
+ }
393
+
211
394
  // ─── Errors ─────────────────────────────────────────────────────
212
395
  let errors = $state<Record<string, string>>({});
213
396
 
@@ -243,7 +426,7 @@ export function useForm<
243
426
 
244
427
  // ─── Reset ──────────────────────────────────────────────────────
245
428
  function reset() {
246
- values = { ...initialValues } as TVariables;
429
+ values = createPersistenceSnapshot(initialValues);
247
430
  tainted = {};
248
431
  clearErrors();
249
432
  queryInitializedId = currentId;
@@ -252,15 +435,15 @@ export function useForm<
252
435
  // ─── Server error handling ──────────────────────────────────────
253
436
 
254
437
  function handleHttpError(error: Error) {
255
- checkError(error);
438
+ checkError(error, adminContext);
256
439
  if (error instanceof HttpError && error.errors && !disableServerSideValidation) {
257
440
  for (const [field, messages] of Object.entries(error.errors)) {
258
441
  const msg = Array.isArray(messages) ? messages[0] : messages;
259
442
  setFieldError(field, msg);
260
443
  }
261
- if (errorNotification !== false) notify({ type: 'error', message: typeof errorNotification === 'string' ? errorNotification : (error.message || t('common.operationFailed')) });
444
+ if (errorNotification !== false) notify({ type: 'error', message: typeof errorNotification === 'string' ? errorNotification : (error.message || i18n.t('common.operationFailed')) });
262
445
  } else {
263
- if (errorNotification !== false) notify({ type: 'error', message: typeof errorNotification === 'string' ? errorNotification : (t('common.operationFailed') + ': ' + error.message) });
446
+ if (errorNotification !== false) notify({ type: 'error', message: typeof errorNotification === 'string' ? errorNotification : (i18n.t('common.operationFailed') + ': ' + error.message) });
264
447
  }
265
448
  }
266
449
 
@@ -285,15 +468,15 @@ export function useForm<
285
468
  if (action === 'create' && queryInitializedId !== undefined) {
286
469
  queryInitializedId = undefined;
287
470
  const def = (options.defaultValues ?? {}) as TVariables;
288
- values = { ...def };
289
- initialValues = { ...def };
471
+ values = createPersistenceSnapshot(def);
472
+ initialValues = createPersistenceSnapshot(def);
290
473
  tainted = {};
291
474
  clearErrors();
292
475
  return;
293
476
  }
294
477
  if (queryInitializedId === currentId) return;
295
478
  const record = (query.data as { data: Record<string, unknown> } | undefined)?.data;
296
- const pk = getResource(resource).primaryKey ?? 'id';
479
+ const pk = adminContext.getResource(resource).primaryKey ?? 'id';
297
480
  if (record && String(record[pk]) === String(currentId)) {
298
481
  const cloneStripKeys = new Set(['id', '_id', pk, 'createdAt', 'updatedAt', 'created_at', 'updated_at', 'createdBy', 'updated_by', 'created_by', 'updatedBy']);
299
482
  const targetData = action === 'clone'
@@ -301,8 +484,10 @@ export function useForm<
301
484
  : record;
302
485
  // Merge: defaultValues < query data
303
486
  const merged = { ...(options.defaultValues ?? {}), ...targetData } as TVariables;
304
- values = merged;
305
- initialValues = { ...merged } as TVariables;
487
+ values = createPersistenceSnapshot(merged);
488
+ initialValues = createPersistenceSnapshot(merged);
489
+ tainted = {};
490
+ clearErrors();
306
491
  queryInitializedId = currentId;
307
492
  }
308
493
  });
@@ -310,6 +495,15 @@ export function useForm<
310
495
 
311
496
  // ─── Mutations ──────────────────────────────────────────────────
312
497
  let redirectOverride: 'list' | 'edit' | 'show' | false | undefined;
498
+ let manualSubmitInProgress = false;
499
+ let manualSubmitIdentity: FormRecordIdentity | null = null;
500
+
501
+ function isManualSubmitIdentityCurrent(): boolean {
502
+ if (formDestroyed) return false;
503
+ return !manualSubmitInProgress
504
+ || manualSubmitIdentity == null
505
+ || isRecordIdentityCurrent(manualSubmitIdentity);
506
+ }
313
507
 
314
508
  const createMut = createMutation(() => ({
315
509
  ...options.createMutationOptions,
@@ -325,21 +519,28 @@ export function useForm<
325
519
  const ctx = context as { targetResource?: string } | undefined;
326
520
  const res = ctx?.targetResource ?? resource;
327
521
  invalidateByScopes(queryClient, res, invalidateScopes, ['list', 'many'], undefined, options.dataProviderName);
328
- if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.createSuccess') });
329
- const pk = getResource(res).primaryKey ?? 'id';
522
+ if (!isManualSubmitIdentityCurrent()) return;
523
+ if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : i18n.t('common.createSuccess') });
524
+ const pk = adminContext.getResource(res).primaryKey ?? 'id';
330
525
  const newId = (data.data as Record<string, unknown>)[pk];
331
526
  audit({ action: 'create', resource: res, recordId: String(newId) });
332
527
  try {
333
- const lp = getLiveProvider();
528
+ const lp = adminContext.liveProvider;
334
529
  if (lp?.publish) lp.publish({ type: 'INSERT', resource: res, payload: { ids: newId != null ? [newId as string | number] : [] } });
335
530
  } catch { /* no live provider */ }
336
531
  onMutationSuccess?.(data);
337
- if (redirectOverride !== false) {
532
+ if (!manualSubmitInProgress && redirectOverride !== false) {
338
533
  if (newId != null) currentId = newId as string | number;
339
534
  doRedirect(redirectOverride ?? redirectDefault);
535
+ } else if (manualSubmitInProgress && redirectOverride !== false && newId != null) {
536
+ currentId = newId as string | number;
340
537
  }
341
538
  },
342
- onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
539
+ onError: (error: Error) => {
540
+ if (!isManualSubmitIdentityCurrent()) return;
541
+ handleHttpError(error);
542
+ onMutationError?.(error);
543
+ },
343
544
  }));
344
545
 
345
546
  const updateMut = createMutation<{ data: TData }, unknown, TVariables>(() => ({
@@ -350,7 +551,7 @@ export function useForm<
350
551
  const targetMeta = mutationMeta;
351
552
  if (mutationMode === 'undoable') {
352
553
  await new Promise<void>((resolve, reject) => {
353
- toast.undoable(t('common.actionCanBeUndone'), undoableTimeout, () => {
554
+ toast.undoable(i18n.t('common.actionCanBeUndone'), undoableTimeout, () => {
354
555
  reject(new UndoError());
355
556
  }, resolve);
356
557
  });
@@ -369,7 +570,7 @@ export function useForm<
369
570
  queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resource && q.queryKey[2] === 'list' }, (old: unknown) => {
370
571
  if (!old || typeof old !== 'object' || !('data' in old)) return old;
371
572
  const o = old as { data: Record<string, unknown>[] };
372
- const pk = getResource(resource).primaryKey ?? 'id';
573
+ const pk = adminContext.getResource(resource).primaryKey ?? 'id';
373
574
  return { ...o, data: o.data.map((item) => String(item[pk]) === String(targetId) ? deepMerge(item, variables) : item) };
374
575
  });
375
576
  }
@@ -382,14 +583,15 @@ export function useForm<
382
583
  const ctx = context as { targetId?: string | number; targetResource?: string } | undefined;
383
584
  const targetId = ctx?.targetId ?? currentId;
384
585
  const res = ctx?.targetResource ?? resource;
385
- if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.updateSuccess') });
586
+ if (!isManualSubmitIdentityCurrent()) return;
587
+ if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : i18n.t('common.updateSuccess') });
386
588
  audit({ action: 'update', resource: res, recordId: String(targetId) });
387
589
  try {
388
- const lp = getLiveProvider();
590
+ const lp = adminContext.liveProvider;
389
591
  if (lp?.publish) lp.publish({ type: 'UPDATE', resource: res, payload: { ids: targetId != null ? [targetId] : [] } });
390
592
  } catch { /* no live provider */ }
391
593
  onMutationSuccess?.(data);
392
- if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
594
+ if (!manualSubmitInProgress && redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
393
595
  },
394
596
  onSettled: (_data: unknown, error: unknown, _vars: unknown, context: unknown) => {
395
597
  if (error instanceof UndoError) return;
@@ -406,44 +608,65 @@ export function useForm<
406
608
  }
407
609
  }
408
610
  if (error instanceof UndoError) return;
611
+ if (!isManualSubmitIdentityCurrent()) return;
409
612
  handleHttpError(error as Error);
410
613
  onMutationError?.(error as Error);
411
614
  },
412
615
  }));
413
616
 
414
617
  function doRedirect(to: 'list' | 'edit' | 'show' | false) {
415
- const path = currentPath().split('?')[0];
618
+ const path = adminContext.currentPath().split('?')[0];
416
619
  const parts = path.split('/');
417
620
  const resIdx = parts.lastIndexOf(String(resource));
418
621
  const base = resIdx >= 0 ? parts.slice(0, resIdx + 1).join('/') : `/${resource}`;
419
622
 
420
- if (to === 'list') navigate(base);
421
- else if (to === 'edit' && currentId) navigate(`${base}/edit/${currentId}`);
422
- else if (to === 'show' && currentId) navigate(`${base}/show/${currentId}`);
623
+ if (to === 'list') void adminContext.navigate(base);
624
+ else if (to === 'edit' && currentId) void adminContext.navigate(`${base}/edit/${currentId}`);
625
+ else if (to === 'show' && currentId) void adminContext.navigate(`${base}/show/${currentId}`);
423
626
  }
424
627
 
425
628
  // ─── Submit ─────────────────────────────────────────────────────
426
629
  async function submit(overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) {
630
+ if (formDestroyed) return;
631
+ const submitIdentity = captureRecordIdentity();
427
632
  if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
428
633
  while (autoSaveInProgress) {
429
634
  await new Promise(r => setTimeout(r, 50));
635
+ if (formDestroyed) return;
430
636
  }
637
+ if (!isRecordIdentityCurrent(submitIdentity)) return;
431
638
  if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
432
639
  autoSaveDirty = false;
433
- if (onSubmitFn) {
434
- let cancelled = false;
435
- const result = await onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
436
- if (result === false || cancelled) return;
437
- }
438
-
439
- if (!runValidation()) { notify({ type: 'warning', message: t('validation.required') }); return; }
640
+ manualSubmitInProgress = true;
641
+ manualSubmitIdentity = submitIdentity;
440
642
  redirectOverride = overrides?.redirect;
441
643
  try {
442
- if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
443
- else await updateMut.mutateAsync(values);
444
- tainted = {};
644
+ if (onSubmitFn) {
645
+ let cancelled = false;
646
+ const result = await onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
647
+ if (result === false || cancelled) return;
648
+ }
649
+
650
+ if (!isRecordIdentityCurrent(submitIdentity)) return;
651
+
652
+ if (!runValidation()) { notify({ type: 'warning', message: i18n.t('validation.required') }); return; }
653
+
654
+ const submittedValues = createPersistenceSnapshot(values);
655
+ if (action === 'create' || action === 'clone') await createMut.mutateAsync(submittedValues);
656
+ else await updateMut.mutateAsync(submittedValues);
657
+
658
+ if (!isRecordIdentityCurrent(submitIdentity)) return;
659
+ reconcilePersistedSnapshot(submittedValues);
660
+ if (!isTainted() && redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
445
661
  } catch {
446
662
  // Errors already handled by mutation's onError callback
663
+ } finally {
664
+ manualSubmitInProgress = false;
665
+ manualSubmitIdentity = null;
666
+ redirectOverride = undefined;
667
+ const shouldAutoSave = !formDestroyed && autoSaveDirty && isTainted();
668
+ autoSaveDirty = false;
669
+ if (shouldAutoSave) deferAutoSaveTrigger();
447
670
  }
448
671
  }
449
672
 
@@ -458,47 +681,88 @@ export function useForm<
458
681
 
459
682
  $effect(() => {
460
683
  return () => {
461
- if (autoSaveTimer) clearTimeout(autoSaveTimer);
462
- if (autoSaveStatusTimer) clearTimeout(autoSaveStatusTimer);
684
+ formDestroyed = true;
685
+ autoSaveDirty = false;
686
+ if (autoSaveTimer) {
687
+ clearTimeout(autoSaveTimer);
688
+ autoSaveTimer = null;
689
+ }
690
+ if (autoSaveStatusTimer) {
691
+ clearTimeout(autoSaveStatusTimer);
692
+ autoSaveStatusTimer = null;
693
+ }
463
694
  };
464
695
  });
465
696
 
697
+ function deferAutoSaveTrigger() {
698
+ if (formDestroyed) return;
699
+ if (autoSaveTimer) clearTimeout(autoSaveTimer);
700
+ autoSaveTimer = setTimeout(() => {
701
+ autoSaveTimer = null;
702
+ if (formDestroyed) return;
703
+ triggerAutoSave();
704
+ }, 0);
705
+ }
706
+
466
707
  function triggerAutoSave() {
708
+ if (formDestroyed) return;
467
709
  const currentAction = action;
468
710
  if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone' || currentId == null) return;
469
- if (createMut.isPending || updateMut.isPending) return;
711
+ if (autoSaveInProgress || manualSubmitInProgress || createMut.isPending || updateMut.isPending) {
712
+ autoSaveDirty = true;
713
+ return;
714
+ }
470
715
  if (autoSaveTimer) clearTimeout(autoSaveTimer);
471
716
 
472
- const safeId = currentId;
473
- const safeResource = resource;
717
+ const scheduledIdentity = captureRecordIdentity();
718
+ const currentSnapshot = createPersistenceSnapshot(values);
719
+ const safeId = scheduledIdentity.id;
720
+ const safeResource = scheduledIdentity.resource;
474
721
  const safeMeta = mutationMeta;
475
722
  autoSaveTimer = setTimeout(async () => {
476
- if (autoSaveInProgress || createMut.isPending || updateMut.isPending) {
723
+ autoSaveTimer = null;
724
+ if (formDestroyed) return;
725
+ if (!isRecordIdentityCurrent(scheduledIdentity)) return;
726
+ if (autoSaveInProgress || manualSubmitInProgress || createMut.isPending || updateMut.isPending) {
477
727
  autoSaveDirty = true;
478
728
  return;
479
729
  }
480
- const finalValues = autoSaveOpts.onFinish ? autoSaveOpts.onFinish(values) : values;
730
+ const finishedValues = autoSaveOpts.onFinish ? autoSaveOpts.onFinish(currentSnapshot) : currentSnapshot;
731
+ const submittedValues = createPersistenceSnapshot(finishedValues);
732
+ if (autoSaveStatusTimer) {
733
+ clearTimeout(autoSaveStatusTimer);
734
+ autoSaveStatusTimer = null;
735
+ }
481
736
  autoSaveStatus = 'saving';
482
737
  autoSaveInProgress = true;
483
738
  autoSaveDirty = false;
484
739
  try {
485
- await provider.update<TData, TVariables>({ resource: safeResource, id: safeId as string | number, variables: finalValues, meta: safeMeta });
740
+ await provider.update<TData, TVariables>({ resource: safeResource, id: safeId as string | number, variables: submittedValues, meta: safeMeta });
741
+ if (formDestroyed) return;
486
742
  const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
487
743
  invalidateByScopes(queryClient, safeResource, scopes, ['resourceAll'], safeId ?? undefined, options.dataProviderName);
744
+ if (!isRecordIdentityCurrent(scheduledIdentity)) return;
488
745
  autoSaveStatus = 'saved';
489
- lastAutoSaveData = finalValues;
746
+ lastAutoSaveData = submittedValues;
490
747
  lastAutoSaveError = null;
491
- initialValues = { ...finalValues } as TVariables;
492
- tainted = {};
493
- if (autoSaveStatusTimer) clearTimeout(autoSaveStatusTimer);
494
- autoSaveStatusTimer = setTimeout(() => { autoSaveStatus = 'idle'; autoSaveStatusTimer = null; }, 2000);
748
+ reconcilePersistedSnapshot(currentSnapshot, submittedValues);
749
+ autoSaveStatusTimer = setTimeout(() => {
750
+ autoSaveStatusTimer = null;
751
+ if (formDestroyed) return;
752
+ autoSaveStatus = 'idle';
753
+ }, 2000);
495
754
  } catch (e) {
755
+ if (!isRecordIdentityCurrent(scheduledIdentity)) return;
496
756
  autoSaveStatus = 'error';
497
757
  lastAutoSaveError = e;
498
- checkError(e);
758
+ checkError(e, adminContext);
499
759
  } finally {
500
- autoSaveInProgress = false;
501
- if (autoSaveDirty) triggerAutoSave();
760
+ if (!formDestroyed) {
761
+ autoSaveInProgress = false;
762
+ const shouldAutoSave = autoSaveDirty && isTainted();
763
+ autoSaveDirty = false;
764
+ if (shouldAutoSave) triggerAutoSave();
765
+ }
502
766
  }
503
767
  }, autoSaveOpts.debounce ?? 1000);
504
768
  }
package/src/helpers.ts CHANGED
@@ -75,8 +75,12 @@ export function deriveValidator(
75
75
  only?: string[];
76
76
  /** Skip these field keys. */
77
77
  except?: string[];
78
+ /** Captured tree-local translator for validation that runs after component initialization. */
79
+ translate?: typeof t;
78
80
  }
79
81
  ): (values: Record<string, unknown>) => Record<string, string> | null {
82
+ const translate = options?.translate ?? t;
83
+
80
84
  return (values: Record<string, unknown>) => {
81
85
  const errors: Record<string, string> = {};
82
86
  const only = options?.only ? new Set(options.only) : null;
@@ -91,12 +95,12 @@ export function deriveValidator(
91
95
  // Required check
92
96
  if (field.required) {
93
97
  if (value === undefined || value === null || value === '') {
94
- errors[field.key] = t('validation.required');
98
+ errors[field.key] = translate('validation.required');
95
99
  continue;
96
100
  }
97
101
  // Empty array check for multi-value fields
98
102
  if (Array.isArray(value) && value.length === 0 && (field.type === 'multiselect' || field.type === 'tags')) {
99
- errors[field.key] = t('validation.required');
103
+ errors[field.key] = translate('validation.required');
100
104
  continue;
101
105
  }
102
106
  }
@@ -109,7 +113,7 @@ export function deriveValidator(
109
113
  case 'email': {
110
114
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
111
115
  if (typeof value === 'string' && !emailRegex.test(value)) {
112
- errors[field.key] = t('validation.invalidEmail');
116
+ errors[field.key] = translate('validation.invalidEmail');
113
117
  }
114
118
  break;
115
119
  }
@@ -117,17 +121,17 @@ export function deriveValidator(
117
121
  try {
118
122
  new URL(value as string);
119
123
  } catch {
120
- errors[field.key] = t('validation.invalidUrl');
124
+ errors[field.key] = translate('validation.invalidUrl');
121
125
  }
122
126
  break;
123
127
  }
124
128
  case 'number': {
125
129
  if (typeof value === 'string' && isNaN(Number(value))) {
126
- errors[field.key] = t('validation.invalidNumber');
130
+ errors[field.key] = translate('validation.invalidNumber');
127
131
  } else if (typeof value === 'number' && isNaN(value)) {
128
- errors[field.key] = t('validation.invalidNumber');
132
+ errors[field.key] = translate('validation.invalidNumber');
129
133
  } else if (typeof value !== 'string' && typeof value !== 'number') {
130
- errors[field.key] = t('validation.invalidNumber');
134
+ errors[field.key] = translate('validation.invalidNumber');
131
135
  }
132
136
  break;
133
137
  }