@svadmin/core 0.23.1 → 0.23.3

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.
Files changed (47) hide show
  1. package/package.json +20 -3
  2. package/src/adapters/casbin.ts +1 -1
  3. package/src/adapters/casl.ts +1 -1
  4. package/src/audit.ts +12 -11
  5. package/src/auth-hooks.svelte.ts +25 -2
  6. package/src/chatProvider.svelte.ts +20 -3
  7. package/src/context.svelte.ts +17 -1
  8. package/src/data-transfer.svelte.ts +23 -11
  9. package/src/form-hooks.svelte.ts +71 -27
  10. package/src/form-hooks.test.svelte.ts +83 -0
  11. package/src/helpers-pure.ts +13 -6
  12. package/src/helpers.test.ts +2 -2
  13. package/src/helpers.ts +4 -0
  14. package/src/hook-utils.svelte.ts +40 -20
  15. package/src/hooks.svelte.ts +43 -50
  16. package/src/i18n.svelte.ts +3 -0
  17. package/src/index.ts +10 -8
  18. package/src/inferencer-openapi.ts +5 -3
  19. package/src/inferencer.ts +6 -4
  20. package/src/live-sse.ts +20 -17
  21. package/src/live-websocket.ts +1 -0
  22. package/src/live.svelte.ts +19 -11
  23. package/src/mutation-hooks.svelte.ts +39 -25
  24. package/src/mutation-hooks.test.svelte.ts +114 -0
  25. package/src/notification.svelte.ts +6 -6
  26. package/src/options.svelte.ts +1 -1
  27. package/src/{permissions.ts → permissions.svelte.ts} +1 -1
  28. package/src/permissions.test.ts +32 -80
  29. package/src/query-hooks.svelte.ts +18 -9
  30. package/src/query-hooks.test.svelte.ts +65 -0
  31. package/src/query-keys.ts +3 -4
  32. package/src/reactive-hooks.test.svelte.ts +26 -0
  33. package/src/router-provider.test.ts +3 -0
  34. package/src/router-provider.ts +13 -9
  35. package/src/router.ts +8 -0
  36. package/src/routing-hooks.svelte.ts +16 -13
  37. package/src/routing-hooks.test.svelte.ts +55 -0
  38. package/src/table-hooks.svelte.ts +46 -1
  39. package/src/table-hooks.test.svelte.ts +57 -0
  40. package/src/table-state.svelte.ts +17 -12
  41. package/src/theme.svelte.ts +25 -6
  42. package/src/toast.svelte.ts +8 -0
  43. package/src/unsaved-changes.svelte.ts +14 -6
  44. package/src/url-sync.ts +2 -1
  45. package/src/useCan.ts +2 -2
  46. package/src/useParsed.svelte.ts +12 -4
  47. package/src/utility-hooks.svelte.ts +5 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.23.1",
3
+ "version": "0.23.3",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,16 @@
9
9
  },
10
10
  "main": "src/index.ts",
11
11
  "devDependencies": {
12
- "@types/bun": "^1.3.11"
12
+ "@happy-dom/global-registrator": "^20.8.9",
13
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
14
+ "@testing-library/dom": "^10.4.1",
15
+ "@testing-library/svelte": "^5.3.1",
16
+ "@testing-library/user-event": "^14.6.1",
17
+ "@types/bun": "^1.3.11",
18
+ "@types/jsdom": "^28.0.1",
19
+ "happy-dom": "^20.8.9",
20
+ "jsdom": "^29.0.2",
21
+ "vitest": "^4.1.4"
13
22
  },
14
23
  "peerDependencies": {
15
24
  "svelte": "^5.0.0",
@@ -61,5 +70,13 @@
61
70
  "sideEffects": false,
62
71
  "type": "module",
63
72
  "types": "src/index.ts",
64
- "dependencies": {}
73
+ "dependencies": {},
74
+ "peerDependenciesMeta": {
75
+ "svelte": {
76
+ "optional": true
77
+ },
78
+ "@tanstack/svelte-query": {
79
+ "optional": true
80
+ }
81
+ }
65
82
  }
@@ -18,7 +18,7 @@
18
18
  * ```
19
19
  */
20
20
 
21
- import type { AccessControlProvider, CanParams, CanResult } from '../permissions';
21
+ import type { AccessControlProvider, CanParams, CanResult } from '../permissions.svelte';
22
22
 
23
23
  /** Casbin Enforcer interface (minimal, to avoid hard dependency on casbin) */
24
24
  interface CasbinEnforcer {
@@ -19,7 +19,7 @@
19
19
  * ```
20
20
  */
21
21
 
22
- import type { AccessControlProvider, CanParams, CanResult } from '../permissions';
22
+ import type { AccessControlProvider, CanParams, CanResult } from '../permissions.svelte';
23
23
 
24
24
  /** CASL Ability interface (minimal, to avoid hard dependency on @casl/ability) */
25
25
  interface CaslAbility {
package/src/audit.ts CHANGED
@@ -53,19 +53,20 @@ export function resetAuditLogProvider(): void {
53
53
  export function audit(entry: Omit<AuditEntry, 'timestamp'>): void {
54
54
  const fullEntry: AuditEntry = { ...entry, timestamp: new Date().toISOString() };
55
55
  try {
56
- handler(fullEntry);
57
-
58
- // Also log via provider if set
59
- if (auditLogProvider) {
60
- auditLogProvider.create({
61
- resource: entry.resource ?? '',
62
- action: entry.action,
63
- data: entry.data ?? entry.details,
64
- previousData: entry.previousData,
65
- meta: entry.meta,
66
- }).catch(e => console.error('[audit] provider create error:', e));
56
+ const result = handler(fullEntry);
57
+ if (result && typeof result === 'object' && 'then' in result) {
58
+ (result as Promise<void>).catch(e => console.error('[audit] handler error:', e));
67
59
  }
68
60
  } catch (e) {
69
61
  console.error('[audit] handler error:', e);
70
62
  }
63
+ if (auditLogProvider) {
64
+ auditLogProvider.create({
65
+ resource: entry.resource ?? '',
66
+ action: entry.action,
67
+ data: entry.data ?? entry.details,
68
+ previousData: entry.previousData,
69
+ meta: entry.meta,
70
+ }).catch(e => console.error('[audit] provider create error:', e));
71
+ }
71
72
  }
@@ -7,6 +7,11 @@ import { navigate } from './router';
7
7
  import { notify } from './notification.svelte';
8
8
  import { t } from './i18n.svelte';
9
9
  import type { AuthActionResult, CheckResult, Identity, AuthProvider } from './types';
10
+ import { useQueryClient } from '@tanstack/svelte-query';
11
+
12
+ let _logoutVersion = $state(0);
13
+ export function getLogoutVersion() { return _logoutVersion; }
14
+ export function resetLogoutVersion() { _logoutVersion = 0; }
10
15
 
11
16
  // ─── Mutate Factory ─────────────────────────────────────────────
12
17
 
@@ -59,7 +64,7 @@ export function useLogin(opts?: { errorMessage?: string | false }) {
59
64
  method: 'login',
60
65
  successMessage: t('common.operationSuccess'),
61
66
  errorMessage: opts?.errorMessage ?? t('common.loginFailed'),
62
- onSuccess: (result) => { if (result.redirectTo) navigate(result.redirectTo); }
67
+ onSuccess: (result) => { navigate(result.redirectTo ?? '/'); }
63
68
  });
64
69
  }
65
70
 
@@ -69,7 +74,14 @@ export function useLogout() {
69
74
  return createAuthMutation({
70
75
  method: 'logout',
71
76
  successMessage: null,
72
- onSuccess: (result) => { navigate(result.redirectTo ?? '/login'); }
77
+ onSuccess: (result) => {
78
+ _logoutVersion++;
79
+ try {
80
+ const queryClient = useQueryClient();
81
+ queryClient.clear();
82
+ } catch {}
83
+ navigate(result.redirectTo ?? '/login');
84
+ }
73
85
  });
74
86
  }
75
87
 
@@ -125,6 +137,10 @@ export function useGetIdentity() {
125
137
 
126
138
  if (typeof window !== 'undefined') {
127
139
  fetch();
140
+ $effect(() => {
141
+ void _logoutVersion;
142
+ data = null;
143
+ });
128
144
  }
129
145
 
130
146
  return {
@@ -170,6 +186,13 @@ export function useIsAuthenticated() {
170
186
  // Initial check
171
187
  check();
172
188
 
189
+ if (typeof window !== 'undefined') {
190
+ $effect(() => {
191
+ void _logoutVersion;
192
+ isAuthenticated = false;
193
+ });
194
+ }
195
+
173
196
  return {
174
197
  get isAuthenticated() { return isAuthenticated; },
175
198
  get isLoading() { return isLoading; },
@@ -148,13 +148,12 @@ export interface AgentOptions {
148
148
  * ```
149
149
  */
150
150
  export interface AgentProvider {
151
- /** Available tools the agent can invoke */
152
151
  tools?: AdminTool[];
153
- /** Send messages and receive a stream of typed agent events */
154
152
  chat(
155
153
  messages: ChatMessage[],
156
154
  options?: AgentOptions,
157
155
  ): AsyncGenerator<AgentEvent, void, unknown>;
156
+ approveToolCall?(id: string, approved: boolean): void;
158
157
  }
159
158
 
160
159
  // ─── Chat Provider singleton ───────────────────────────────────
@@ -190,14 +189,22 @@ interface ApprovalEntry { id: string; callback: ApprovalCallback }
190
189
  let pendingApprovals = $state<ApprovalEntry[]>([]);
191
190
 
192
191
  export function registerApproval(id: string, callback: ApprovalCallback): void {
192
+ if (pendingApprovals.some(e => e.id === id)) return;
193
193
  pendingApprovals = [...pendingApprovals, { id, callback }];
194
194
  }
195
195
 
196
196
  export function resolveApproval(id: string, approved: boolean): boolean {
197
197
  const entry = pendingApprovals.find(e => e.id === id);
198
198
  if (!entry) return false;
199
- entry.callback(approved);
200
199
  pendingApprovals = pendingApprovals.filter(e => e.id !== id);
200
+ try {
201
+ const result: unknown = entry.callback(approved);
202
+ if (result && typeof result === 'object' && 'then' in result) {
203
+ (result as Promise<unknown>).catch((err) => console.error('[svadmin] Approval callback error:', err));
204
+ }
205
+ } catch (err) {
206
+ console.error('[svadmin] Approval callback error:', err);
207
+ }
201
208
  return true;
202
209
  }
203
210
 
@@ -216,3 +223,13 @@ export function setChatContext(ctx: ChatContext): void {
216
223
  export function getChatContext(): ChatContext {
217
224
  return chatContext;
218
225
  }
226
+
227
+ export function resetChatProvider(): void {
228
+ chatProvider = null;
229
+ agentProvider = null;
230
+ for (const entry of pendingApprovals) {
231
+ try { entry.callback(false); } catch { /* noop */ }
232
+ }
233
+ pendingApprovals = [];
234
+ chatContext = {};
235
+ }
@@ -5,9 +5,17 @@ import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
5
5
  import type { RouterProvider } from './router-provider';
6
6
  import { resetNotificationProvider } from './notification.svelte';
7
7
  import { resetAuditLogProvider } from './audit';
8
- import { resetAccessControlProvider } from './permissions';
8
+ import { resetAccessControlProvider } from './permissions.svelte';
9
9
  import { resetAdminOptions } from './options.svelte';
10
10
  import { resetI18n } from './i18n.svelte';
11
+ import { resetChatProvider } from './chatProvider.svelte';
12
+ import { resetToast } from './toast.svelte';
13
+ import { resetTheme } from './theme.svelte';
14
+ import { resetRouter } from './router';
15
+ import { resetUnsavedChanges } from './unsaved-changes.svelte';
16
+ import { resetGlobalPath } from './useParsed.svelte';
17
+ import { resetLogoutVersion } from './auth-hooks.svelte';
18
+ import { resetSidebarCollapsed } from './hooks.svelte';
11
19
 
12
20
  // ─── DataProvider (supports single or multiple) ─────────────────
13
21
 
@@ -141,4 +149,12 @@ export function resetContext(): void {
141
149
  resetAccessControlProvider();
142
150
  resetAdminOptions();
143
151
  resetI18n();
152
+ resetChatProvider();
153
+ resetToast();
154
+ resetTheme();
155
+ resetRouter();
156
+ resetUnsavedChanges();
157
+ resetGlobalPath();
158
+ resetLogoutVersion();
159
+ resetSidebarCollapsed();
144
160
  }
@@ -25,10 +25,11 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
25
25
  let isLoading = $state(false);
26
26
 
27
27
  async function triggerExport() {
28
- const provider = getDataProviderForResource(resource, options.dataProviderName);
28
+ const exportResource = resource;
29
29
  isLoading = true;
30
30
 
31
31
  try {
32
+ const provider = getDataProviderForResource(exportResource, options.dataProviderName);
32
33
  const batchSize = options.pageSize ?? 20;
33
34
  const maxItems = options.maxItemCount ?? Infinity;
34
35
  let allRecords: TData[] = [];
@@ -36,7 +37,7 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
36
37
 
37
38
  while (allRecords.length < maxItems) {
38
39
  const result = await provider.getList<TData>({
39
- resource,
40
+ resource: exportResource,
40
41
  pagination: { current: page, pageSize: batchSize },
41
42
  sorters: options.sorters,
42
43
  filters: options.filters,
@@ -57,7 +58,11 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
57
58
 
58
59
  if (options.download !== false && typeof document !== 'undefined') {
59
60
  const fields = Object.keys(mapped[0]);
60
- const escapeCsvField = (s: string) => s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r') ? `"${s.replace(/"/g, '""')}"` : s;
61
+ const escapeCsvField = (s: string) => {
62
+ let val = s;
63
+ if (/^[=+@\t\r]/.test(val)) val = "'" + val;
64
+ return val.includes(',') || val.includes('"') || val.includes('\n') || val.includes('\r') ? `"${val.replace(/"/g, '""')}"` : val;
65
+ };
61
66
  const header = fields.map(escapeCsvField).join(',');
62
67
  const rows = mapped.map(record =>
63
68
  fields.map(f => {
@@ -72,9 +77,9 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
72
77
  const url = URL.createObjectURL(blob);
73
78
  const a = document.createElement('a');
74
79
  a.href = url;
75
- a.download = `${resource}_export.csv`;
80
+ a.download = `${exportResource}_export.csv`;
76
81
  a.click();
77
- URL.revokeObjectURL(url);
82
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
78
83
  }
79
84
 
80
85
  return allRecords;
@@ -113,12 +118,13 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
113
118
  let mutationResult = $state<{ succeeded: unknown[]; errored: { request: unknown; error: unknown }[] } | null>(null);
114
119
 
115
120
  async function handleChange(info: { file: File }) {
116
- const provider = getDataProviderForResource(resource, options.dataProviderName);
121
+ const importResource = resource;
117
122
  isLoading = true;
118
123
  const succeeded: unknown[] = [];
119
124
  const errored: { request: unknown; error: unknown }[] = [];
120
125
 
121
126
  try {
127
+ const provider = getDataProviderForResource(importResource, options.dataProviderName);
122
128
  let text = await info.file.text();
123
129
  // Strip BOM
124
130
  if (text.charCodeAt(0) === 0xFEFF) {
@@ -126,7 +132,11 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
126
132
  }
127
133
 
128
134
  const rows = parseCSV(text);
129
- if (rows.length < 2) return;
135
+ if (rows.length < 2) {
136
+ mutationResult = { succeeded: [], errored: [] };
137
+ options.onFinish?.({ succeeded: [], errored: [] });
138
+ return;
139
+ }
130
140
 
131
141
  const headers = rows[0];
132
142
  const records: Record<string, unknown>[] = [];
@@ -144,7 +154,7 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
144
154
  if (batchSize === 1) {
145
155
  for (const record of records) {
146
156
  try {
147
- const res = await provider.create({ resource, variables: record, meta: options.meta });
157
+ const res = await provider.create({ resource: importResource, variables: record, meta: options.meta });
148
158
  succeeded.push(res);
149
159
  } catch (error) {
150
160
  errored.push({ request: record, error });
@@ -157,16 +167,15 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
157
167
  const batch = records.slice(i, i + batchSize);
158
168
  if (provider.createMany) {
159
169
  try {
160
- const res = await provider.createMany({ resource, variables: batch, meta: options.meta });
170
+ const res = await provider.createMany({ resource: importResource, variables: batch, meta: options.meta });
161
171
  succeeded.push(res);
162
172
  } catch (error) {
163
173
  errored.push({ request: batch, error });
164
174
  }
165
175
  } else {
166
- // Fallback: loop internal items inside batch securely instead of breaking whole batch
167
176
  for (const record of batch) {
168
177
  try {
169
- const res = await provider.create({ resource, variables: record, meta: options.meta });
178
+ const res = await provider.create({ resource: importResource, variables: record, meta: options.meta });
170
179
  succeeded.push(res);
171
180
  } catch (error) {
172
181
  errored.push({ request: record, error });
@@ -180,6 +189,9 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
180
189
 
181
190
  mutationResult = { succeeded, errored };
182
191
  options.onFinish?.({ succeeded, errored });
192
+ } catch (error) {
193
+ mutationResult = { succeeded, errored: [...errored, { request: null, error }] };
194
+ options.onFinish?.({ succeeded, errored: mutationResult.errored });
183
195
  } finally {
184
196
  isLoading = false;
185
197
  }
@@ -246,7 +246,7 @@ export function useForm<
246
246
  values = { ...initialValues } as TVariables;
247
247
  tainted = {};
248
248
  clearErrors();
249
- queryInitializedId = undefined;
249
+ queryInitializedId = currentId;
250
250
  }
251
251
 
252
252
  // ─── Server error handling ──────────────────────────────────────
@@ -313,17 +313,25 @@ export function useForm<
313
313
 
314
314
  const createMut = createMutation(() => ({
315
315
  ...options.createMutationOptions,
316
- mutationFn: (variables: TVariables) => provider.create<TData, TVariables>({ resource, variables, meta: mutationMeta }),
317
- onSuccess: (data: { data: TData }) => {
318
- invalidateByScopes(queryClient, resource, invalidateScopes, ['list', 'many']);
316
+ mutationFn: (variables: unknown) => {
317
+ const targetResource = resource;
318
+ const targetMeta = mutationMeta;
319
+ return provider.create<TData, TVariables>({ resource: targetResource, variables: variables as TVariables, meta: targetMeta });
320
+ },
321
+ onMutate: async () => {
322
+ return { targetResource: resource };
323
+ },
324
+ onSuccess: (data: { data: TData }, _vars: unknown, context: unknown) => {
325
+ const ctx = context as { targetResource?: string } | undefined;
326
+ const res = ctx?.targetResource ?? resource;
327
+ invalidateByScopes(queryClient, res, invalidateScopes, ['list', 'many'], undefined, options.dataProviderName);
319
328
  if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.createSuccess') });
320
- const pk = getResource(resource).primaryKey ?? 'id';
329
+ const pk = getResource(res).primaryKey ?? 'id';
321
330
  const newId = (data.data as Record<string, unknown>)[pk];
322
- audit({ action: 'create', resource, recordId: String(newId) });
323
- // Publish live event
331
+ audit({ action: 'create', resource: res, recordId: String(newId) });
324
332
  try {
325
333
  const lp = getLiveProvider();
326
- if (lp?.publish) lp.publish({ type: 'INSERT', resource, payload: { ids: newId != null ? [newId as string | number] : [] } });
334
+ if (lp?.publish) lp.publish({ type: 'INSERT', resource: res, payload: { ids: newId != null ? [newId as string | number] : [] } });
327
335
  } catch { /* no live provider */ }
328
336
  onMutationSuccess?.(data);
329
337
  if (redirectOverride !== false) {
@@ -337,6 +345,9 @@ export function useForm<
337
345
  const updateMut = createMutation<{ data: TData }, unknown, TVariables>(() => ({
338
346
  ...options.updateMutationOptions,
339
347
  mutationFn: async (variables: TVariables) => {
348
+ const targetId = currentId!;
349
+ const targetResource = resource;
350
+ const targetMeta = mutationMeta;
340
351
  if (mutationMode === 'undoable') {
341
352
  await new Promise<void>((resolve, reject) => {
342
353
  toast.undoable(t('common.actionCanBeUndone'), undoableTimeout, () => {
@@ -344,38 +355,48 @@ export function useForm<
344
355
  }, resolve);
345
356
  });
346
357
  }
347
- return provider.update<TData, TVariables>({ resource, id: currentId!, variables, meta: mutationMeta });
358
+ return provider.update<TData, TVariables>({ resource: targetResource, id: targetId, variables, meta: targetMeta });
348
359
  },
349
360
  onMutate: async (variables: TVariables) => {
350
- if (mutationMode === 'pessimistic') return;
351
- await queryClient.cancelQueries({ predicate: (q) => q.queryKey[1] === resource });
352
- const previousQueries = queryClient.getQueriesData({ predicate: (q) => q.queryKey[1] === resource });
361
+ if (mutationMode === 'pessimistic') return { targetId: currentId, targetResource: resource };
362
+ const targetId = currentId;
363
+ const targetResource = resource;
364
+ const dpN = options.dataProviderName;
365
+ const dp = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpN;
366
+ await queryClient.cancelQueries({ predicate: (q) => dp(q) && q.queryKey[1] === resource });
367
+ const previousQueries = queryClient.getQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resource });
353
368
  if (optimisticUpdateMap?.list !== false) {
354
- queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'list' }, (old: unknown) => {
369
+ queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resource && q.queryKey[2] === 'list' }, (old: unknown) => {
355
370
  if (!old || typeof old !== 'object' || !('data' in old)) return old;
356
371
  const o = old as { data: Record<string, unknown>[] };
357
372
  const pk = getResource(resource).primaryKey ?? 'id';
358
- return { ...o, data: o.data.map((item) => String(item[pk]) === String(currentId) ? deepMerge(item, variables) : item) };
373
+ return { ...o, data: o.data.map((item) => String(item[pk]) === String(targetId) ? deepMerge(item, variables) : item) };
359
374
  });
360
375
  }
361
- if (optimisticUpdateMap?.detail !== false && currentId != null) {
362
- queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === currentId }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, variables) } : old);
376
+ if (optimisticUpdateMap?.detail !== false && targetId != null) {
377
+ queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === targetId }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, variables) } : old);
363
378
  }
364
- return { previousQueries };
379
+ return { previousQueries, targetId, targetResource };
365
380
  },
366
- onSuccess: (data: unknown) => {
381
+ onSuccess: (data: unknown, _vars: unknown, context: unknown) => {
382
+ const ctx = context as { targetId?: string | number; targetResource?: string } | undefined;
383
+ const targetId = ctx?.targetId ?? currentId;
384
+ const res = ctx?.targetResource ?? resource;
367
385
  if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.updateSuccess') });
368
- audit({ action: 'update', resource, recordId: String(currentId) });
386
+ audit({ action: 'update', resource: res, recordId: String(targetId) });
369
387
  try {
370
388
  const lp = getLiveProvider();
371
- if (lp?.publish) lp.publish({ type: 'UPDATE', resource, payload: { ids: currentId != null ? [currentId] : [] } });
389
+ if (lp?.publish) lp.publish({ type: 'UPDATE', resource: res, payload: { ids: targetId != null ? [targetId] : [] } });
372
390
  } catch { /* no live provider */ }
373
391
  onMutationSuccess?.(data);
374
392
  if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
375
393
  },
376
- onSettled: (_data: unknown, error: unknown) => {
394
+ onSettled: (_data: unknown, error: unknown, _vars: unknown, context: unknown) => {
377
395
  if (error instanceof UndoError) return;
378
- invalidateByScopes(queryClient, resource, invalidateScopes, ['list', 'many', 'detail'], currentId ?? undefined);
396
+ const ctx = context as { targetId?: string | number; targetResource?: string } | undefined;
397
+ const targetId = ctx?.targetId ?? currentId;
398
+ const res = ctx?.targetResource ?? resource;
399
+ invalidateByScopes(queryClient, res, invalidateScopes, ['list', 'many', 'detail'], targetId ?? undefined, options.dataProviderName);
379
400
  },
380
401
  onError: (error: unknown, _vars: unknown, context: unknown) => {
381
402
  const ctx = context as { previousQueries?: [unknown, unknown][] } | undefined;
@@ -404,9 +425,14 @@ export function useForm<
404
425
  // ─── Submit ─────────────────────────────────────────────────────
405
426
  async function submit(overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) {
406
427
  if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
428
+ while (autoSaveInProgress) {
429
+ await new Promise(r => setTimeout(r, 50));
430
+ }
431
+ if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
432
+ autoSaveDirty = false;
407
433
  if (onSubmitFn) {
408
434
  let cancelled = false;
409
- const result = onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
435
+ const result = await onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
410
436
  if (result === false || cancelled) return;
411
437
  }
412
438
 
@@ -424,12 +450,16 @@ export function useForm<
424
450
  // ─── AutoSave ───────────────────────────────────────────────────
425
451
  let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
426
452
  let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
453
+ let autoSaveStatusTimer: ReturnType<typeof setTimeout> | null = null;
454
+ let autoSaveInProgress = $state(false);
455
+ let autoSaveDirty = false;
427
456
  let lastAutoSaveData = $state<unknown>(null);
428
457
  let lastAutoSaveError = $state<unknown>(null);
429
458
 
430
459
  $effect(() => {
431
460
  return () => {
432
461
  if (autoSaveTimer) clearTimeout(autoSaveTimer);
462
+ if (autoSaveStatusTimer) clearTimeout(autoSaveStatusTimer);
433
463
  };
434
464
  });
435
465
 
@@ -440,28 +470,42 @@ export function useForm<
440
470
  if (autoSaveTimer) clearTimeout(autoSaveTimer);
441
471
 
442
472
  const safeId = currentId;
473
+ const safeResource = resource;
474
+ const safeMeta = mutationMeta;
443
475
  autoSaveTimer = setTimeout(async () => {
476
+ if (autoSaveInProgress || createMut.isPending || updateMut.isPending) {
477
+ autoSaveDirty = true;
478
+ return;
479
+ }
444
480
  const finalValues = autoSaveOpts.onFinish ? autoSaveOpts.onFinish(values) : values;
445
481
  autoSaveStatus = 'saving';
482
+ autoSaveInProgress = true;
483
+ autoSaveDirty = false;
446
484
  try {
447
- await provider.update<TData, TVariables>({ resource, id: safeId as string | number, variables: finalValues, meta: mutationMeta });
485
+ await provider.update<TData, TVariables>({ resource: safeResource, id: safeId as string | number, variables: finalValues, meta: safeMeta });
448
486
  const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
449
- invalidateByScopes(queryClient, resource, scopes, ['resourceAll'], safeId ?? undefined);
487
+ invalidateByScopes(queryClient, safeResource, scopes, ['resourceAll'], safeId ?? undefined, options.dataProviderName);
450
488
  autoSaveStatus = 'saved';
451
489
  lastAutoSaveData = finalValues;
452
490
  lastAutoSaveError = null;
453
- setTimeout(() => { autoSaveStatus = 'idle'; }, 2000);
491
+ initialValues = { ...finalValues } as TVariables;
492
+ tainted = {};
493
+ if (autoSaveStatusTimer) clearTimeout(autoSaveStatusTimer);
494
+ autoSaveStatusTimer = setTimeout(() => { autoSaveStatus = 'idle'; autoSaveStatusTimer = null; }, 2000);
454
495
  } catch (e) {
455
496
  autoSaveStatus = 'error';
456
497
  lastAutoSaveError = e;
457
498
  checkError(e);
499
+ } finally {
500
+ autoSaveInProgress = false;
501
+ if (autoSaveDirty) triggerAutoSave();
458
502
  }
459
503
  }, autoSaveOpts.debounce ?? 1000);
460
504
  }
461
505
 
462
506
  if (autoSaveOpts?.invalidateOnUnmount) {
463
507
  $effect(() => {
464
- return () => queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource });
508
+ return () => queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === options.dataProviderName && q.queryKey[1] === resource });
465
509
  });
466
510
  }
467
511
 
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { useForm } from './form-hooks.svelte';
3
+ import { flushSync } from 'svelte';
4
+ import { QueryClient } from '@tanstack/svelte-query';
5
+
6
+ vi.mock('./context.svelte', () => ({
7
+ useDataProvider: () => ({
8
+ getOne: vi.fn(),
9
+ create: vi.fn().mockResolvedValue({ data: { id: 1 } }),
10
+ update: vi.fn().mockResolvedValue({ data: { id: 1 } })
11
+ }),
12
+ useResource: () => ({ name: 'posts' }),
13
+ getResource: () => ({ name: 'posts', primaryKey: 'id' }),
14
+ useRouterContext: () => ({ navigate: vi.fn() }),
15
+ useTranslate: () => (key: string) => key,
16
+ useNotification: () => ({ open: vi.fn(), close: vi.fn() })
17
+ }));
18
+
19
+ vi.mock('@tanstack/svelte-query', async (importOriginal) => {
20
+ const actual = await importOriginal();
21
+ return {
22
+ ...actual as any,
23
+ useQueryClient: () => new QueryClient(),
24
+ createQuery: () => ({ data: { data: { id: 1, title: 'Original' } }, isPending: false }),
25
+ createMutation: (opts: any) => ({
26
+ mutate: vi.fn((vars, callbacks) => {
27
+ if (opts.mutationFn) opts.mutationFn(vars);
28
+ if (callbacks?.onSuccess) callbacks.onSuccess({ data: {} });
29
+ }),
30
+ isPending: false
31
+ })
32
+ };
33
+ });
34
+
35
+ describe('useForm - Headless Svelte 5 Compatibility', () => {
36
+
37
+ it('preserves reactive proxy data bindings safely across Svelte boundaries', () => {
38
+ let form: ReturnType<typeof useForm>;
39
+
40
+ const cleanup = $effect.root(() => {
41
+ form = useForm({
42
+ action: 'create',
43
+ resource: 'posts'
44
+ });
45
+ });
46
+
47
+ flushSync();
48
+
49
+ // Properly use setFieldValue which guarantees state sync in svadmin forms
50
+ form!.setFieldValue('title', 'Reactive Test');
51
+
52
+ flushSync();
53
+
54
+ // The internal $state proxy absorbs this mutation
55
+ expect(form!.values.title).toBe('Reactive Test');
56
+
57
+ cleanup();
58
+ });
59
+
60
+ it('handles autoSave queuing correctly across reactive boundaries', () => {
61
+ let form: ReturnType<typeof useForm>;
62
+
63
+ const cleanup = $effect.root(() => {
64
+ form = useForm({
65
+ action: 'edit',
66
+ resource: 'posts',
67
+ id: 1,
68
+ autoSave: { enabled: true, debounce: 0 }
69
+ });
70
+ });
71
+
72
+ flushSync();
73
+
74
+ form!.setFieldValue('title', 'Saved Title');
75
+
76
+ flushSync();
77
+
78
+ expect(form!.mutation).toBeDefined();
79
+ expect((form!.mutation as any).isPending).toBeDefined();
80
+
81
+ cleanup();
82
+ });
83
+ });