@nsail/stack 0.1.966 → 0.1.977

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/form.d.ts CHANGED
@@ -9,7 +9,11 @@ export interface Form<T> {
9
9
  /** What the server last refused, kept until the field it is about changes. */
10
10
  readonly problem: Problem | null;
11
11
  set<K extends keyof T>(name: K, value: T[K]): void;
12
- reset(value: T): void;
12
+ /** Replaces the value with another record or with what the server holds; `saved` is cleared unless
13
+ * `keepSaved` says the value is the re-read of what was just sent. */
14
+ reset(value: T, options?: {
15
+ keepSaved?: boolean;
16
+ }): void;
13
17
  issues(name: keyof T): Issue[];
14
18
  submit(action: (value: T) => Promise<void>): Promise<boolean>;
15
19
  }
package/dist/form.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (c) 2026 Leonardo Porro and Emmanuel Arias. https://github.com/nsail-ar/nsail-stack
3
- import { useCallback, useMemo, useState } from "react";
3
+ import { useCallback, useMemo, useRef, useState } from "react";
4
4
  import { memberOf } from "./descriptors";
5
5
  import { validate, validateField } from "./validation";
6
6
  import { problemOf } from "./load";
@@ -12,17 +12,24 @@ export function useForm(message, initial) {
12
12
  const [problem, setProblem] = useState(null);
13
13
  const [submitting, setSubmitting] = useState(false);
14
14
  const [saved, setSaved] = useState(false);
15
+ const sending = useRef(false);
16
+ const edits = useRef(0);
15
17
  const set = useCallback((name, next) => {
16
18
  setValue((current) => ({ ...current, [name]: next }));
17
19
  setTouched((current) => new Set(current).add(name));
18
20
  setSaved(false);
21
+ edits.current++;
19
22
  const member = memberOf(message.fields[name]);
20
23
  setProblem((current) => (current ? { ...current, issues: current.issues.filter((issue) => issue.source !== member) } : current));
21
24
  }, [message]);
22
- const reset = useCallback((next) => {
25
+ const reset = useCallback((next, options) => {
23
26
  setValue(next);
24
27
  setTouched(new Set());
25
28
  setProblem(null);
29
+ if (!options?.keepSaved) {
30
+ setSaved(false);
31
+ }
32
+ edits.current++;
26
33
  }, []);
27
34
  const issues = useCallback((name) => {
28
35
  const field = message.fields[name];
@@ -32,18 +39,27 @@ export function useForm(message, initial) {
32
39
  return [...local, ...remote.filter((r) => !local.some((l) => l.code === r.code))];
33
40
  }, [message, touched, value, problem]);
34
41
  const submit = useCallback(async (action) => {
42
+ // A ref, not `submitting`: two calls in one tick both see the state unchanged.
43
+ if (sending.current) {
44
+ return false;
45
+ }
35
46
  const refused = validate(message, value);
36
47
  if (refused) {
37
48
  setTouched(new Set(Object.keys(message.fields)));
38
49
  setProblem(refused);
39
50
  return false;
40
51
  }
52
+ sending.current = true;
53
+ // Counted in the handler, not in a state updater, which StrictMode runs twice.
54
+ const sentAt = edits.current;
41
55
  setSubmitting(true);
42
56
  setSaved(false);
43
57
  setProblem(null);
44
58
  try {
45
59
  await action(value);
46
- setSaved(true);
60
+ if (edits.current === sentAt) {
61
+ setSaved(true);
62
+ }
47
63
  return true;
48
64
  }
49
65
  catch (error) {
@@ -51,6 +67,7 @@ export function useForm(message, initial) {
51
67
  return false;
52
68
  }
53
69
  finally {
70
+ sending.current = false;
54
71
  setSubmitting(false);
55
72
  }
56
73
  }, [message, value]);
package/dist/load.js CHANGED
@@ -24,7 +24,11 @@ export function useLoad(message, request) {
24
24
  setLoad((current) => ({ state: "loading", data: current.data, problem: null }));
25
25
  mediator
26
26
  .send(message, latest.current, { signal: abort.signal })
27
- .then((data) => setLoad({ state: "loaded", data, problem: null }))
27
+ .then((data) => {
28
+ if (!abort.signal.aborted) {
29
+ setLoad({ state: "loaded", data, problem: null });
30
+ }
31
+ })
28
32
  .catch((error) => {
29
33
  if (!abort.signal.aborted) {
30
34
  setLoad((current) => ({ state: "failed", data: current.data, problem: problemOf(error) }));
package/dist/mediator.js CHANGED
@@ -36,14 +36,28 @@ export class Mediator {
36
36
  }
37
37
  throw new BusinessError(requestFailed(null, null));
38
38
  }
39
- const text = await response.text();
39
+ let text;
40
+ try {
41
+ text = await response.text();
42
+ }
43
+ catch (error) {
44
+ if (error instanceof DOMException && error.name === "AbortError") {
45
+ throw error;
46
+ }
47
+ throw new BusinessError(requestFailed(response.status, "DeserializationFailed"));
48
+ }
40
49
  if (!response.ok) {
41
50
  throw new BusinessError(this.problemOf(response.status, text));
42
51
  }
43
52
  if (text.length === 0) {
44
53
  return undefined;
45
54
  }
46
- return JSON.parse(text);
55
+ try {
56
+ return JSON.parse(text);
57
+ }
58
+ catch {
59
+ throw new BusinessError(requestFailed(response.status, "DeserializationFailed"));
60
+ }
47
61
  }
48
62
  url(message, request) {
49
63
  const values = request;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsail/stack",
3
- "version": "0.1.966",
3
+ "version": "0.1.977",
4
4
  "description": "NSail's vendor-free client half: sends messages from the descriptors an Sdk generates, speaks the catalog, and holds the hooks a page reads with.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,6 +30,10 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "peerDependencies": {
33
- "react": ">=19"
33
+ "react": ">=19",
34
+ "react-dom": ">=19"
35
+ },
36
+ "devDependencies": {
37
+ "happy-dom": "^21.0.4"
34
38
  }
35
39
  }