@stapel/listings-react 0.28.0 → 0.28.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # @stapel/listings-react
2
2
 
3
+ ## 0.28.1
4
+
5
+ ### Patch Changes
6
+
7
+ - ef75b1b: listings: a confirmation toast no longer outlives the surface that spoke it — and the suite now says so per test
8
+
9
+ **The defect, as CI met it.** `@stapel/listings-react#test` was failing two runs
10
+ in three on inputs whose turbo hash had not moved: all tests passed, and the job
11
+ then died on an unhandled `ReferenceError: window is not defined` thrown out of
12
+ react-dom, blamed on `test/favoriteFeedback.test.tsx` and `test/detail.test.tsx`.
13
+ Neither file was at fault, and no `--pool` setting was going to fix it.
14
+
15
+ **Root cause.** `useNotice()` raises its confirmation through a holder mounted
16
+ OUTSIDE the component tree — antd's `<App>` holder where a host mounted one, and
17
+ otherwise a React root antd's static `message` entry renders into the document on
18
+ first use. Nothing unmounts either when the surface that spoke unmounts. And a
19
+ standing notice is not an idle DOM node: `@rc-component/notification` counts its
20
+ `NOTICE_SECONDS` down with a **`requestAnimationFrame` loop**
21
+ (`useNoticeTimer`), and `@rc-component/util`'s `raf` captured
22
+ `window.requestAnimationFrame` at module load. So the listing page's heart toast
23
+ kept stepping frames after the test that pressed it, after `cleanup()`, and past
24
+ the end of the file — and the frame that landed once vitest had torn the jsdom
25
+ environment down called into a `window` that no longer existed.
26
+
27
+ **The fix, in the source.** `useNotice` now keeps the handle antd returns for
28
+ each notice it raises and **retires them on unmount** (each handle also retires
29
+ itself the moment its notice closes on its own, so the set holds only what is
30
+ still on screen). Both arms, contextual and static.
31
+
32
+ **Behaviour change, and it is the intent.** A person who presses the heart and
33
+ immediately navigates away no longer gets "Saved" floating over the next page,
34
+ about a listing they have left. The toast has always been the AMPLIFIER of a
35
+ gesture on a surface and never the record of it — the heart's own fill and the
36
+ share menu's own sentence are the record — so an amplifier with nothing left to
37
+ amplify is closed rather than left running. A host that wants a confirmation to
38
+ survive the surface should raise it from a component that survives too.
39
+
40
+ **The gate.** `test/vitest.setup.ts` now asserts, after `cleanup()` and per test,
41
+ that the test left behind **no pending animation frame** and **no unhandled
42
+ rejection**, both read-and-reset so a leaking test reddens itself and not the
43
+ twenty after it. Verified to fail on the unfixed source, on exactly the two
44
+ files CI named, and to pass on the fixed one. `favoriteFeedback.test.tsx` also
45
+ now awaits the write its detail-page gesture starts, instead of ending with a
46
+ POST in flight.
47
+
3
48
  ## 0.28.0
4
49
 
5
50
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"notice.d.ts","sourceRoot":"","sources":["../../src/default/notice.ts"],"names":[],"mappings":"AAoCA,2EAA2E;AAC3E,MAAM,MAAM,MAAM,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;AAE5C;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC,wBAAgB,SAAS,IAAI,MAAM,CAiBlC"}
1
+ {"version":3,"file":"notice.d.ts","sourceRoot":"","sources":["../../src/default/notice.ts"],"names":[],"mappings":"AA0DA,2EAA2E;AAC3E,MAAM,MAAM,MAAM,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;AAU5C;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC,wBAAgB,SAAS,IAAI,MAAM,CAuClC"}
@@ -30,8 +30,30 @@
30
30
  * the heart's state is on the heart. This is the AMPLIFIER, never the record.
31
31
  * A future `SkinNotice` in the substrate replaces the body of this function
32
32
  * and nothing else; the callers already speak in resolved sentences.
33
+ *
34
+ * ── An amplifier may not outlive what it amplifies ────────────────────────
35
+ *
36
+ * Both arms hand the notice to a holder MOUNTED OUTSIDE this component tree —
37
+ * antd's `<App>` holder in the first arm, and in the second a React root antd
38
+ * renders into the document the first time the static entry is called. Neither
39
+ * is unmounted by unmounting the surface that spoke, and a standing notice is
40
+ * not an idle DOM node: `@rc-component/notification` counts its two seconds
41
+ * down with a `requestAnimationFrame` LOOP (`useNoticeTimer`), which is live
42
+ * work driven from a root nothing in this package owns.
43
+ *
44
+ * So the notices raised here are RETIRED when the surface that raised them
45
+ * goes. Two things follow, and both are the intent:
46
+ *
47
+ * - a person who presses the heart and immediately navigates away does not
48
+ * get "Saved" floating over the next page, about a listing they left;
49
+ * - nothing this pair started keeps running after the tree it started in is
50
+ * gone. Measured as CI flake: the detail page's heart toast kept stepping
51
+ * its rAF loop past the end of the test file that raised it, and the frame
52
+ * that landed after the environment was torn down threw
53
+ * `ReferenceError: window is not defined` out of react-dom — from a suite
54
+ * in which every test had passed.
33
55
  */
34
- import { useCallback } from "react";
56
+ import { useCallback, useEffect, useRef } from "react";
35
57
  import { App, message as staticMessage } from "antd";
36
58
  /**
37
59
  * How long a confirmation stands, in seconds.
@@ -46,9 +68,29 @@ export const NOTICE_SECONDS = 2;
46
68
  export function useNotice() {
47
69
  const app = App.useApp();
48
70
  const contextual = app.message.success;
71
+ // The notices this surface has raised and that have not closed themselves
72
+ // yet — see the header. A notice retires itself the moment it closes, so
73
+ // this holds at most the handful still on screen.
74
+ const standing = useRef(new Set());
75
+ useEffect(() => () => {
76
+ const open = standing.current;
77
+ standing.current = new Set();
78
+ for (const close of open)
79
+ close();
80
+ }, []);
81
+ const hold = useCallback((raised) => {
82
+ standing.current.add(raised);
83
+ const retire = () => {
84
+ standing.current.delete(raised);
85
+ };
86
+ // Closed by its own timer, by our unmount, or by a host calling
87
+ // `message.destroy()` — every ending resolves this, and a rejection is an
88
+ // ending too. Nothing is awaited: the notice is already on screen.
89
+ raised.then(retire, retire);
90
+ }, []);
49
91
  return useCallback((text) => {
50
92
  if (typeof contextual === "function") {
51
- contextual(text, NOTICE_SECONDS);
93
+ hold(contextual(text, NOTICE_SECONDS));
52
94
  return;
53
95
  }
54
96
  // No `<App>` above us. antd's static entry renders its own holder into
@@ -56,7 +98,7 @@ export function useNotice() {
56
98
  // and is a no-op on a server where there is no document to render into.
57
99
  if (typeof document === "undefined")
58
100
  return;
59
- staticMessage.success(text, NOTICE_SECONDS);
60
- }, [contextual]);
101
+ hold(staticMessage.success(text, NOTICE_SECONDS));
102
+ }, [contextual, hold]);
61
103
  }
62
104
  //# sourceMappingURL=notice.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"notice.js","sourceRoot":"","sources":["../../src/default/notice.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AACpC,OAAO,EAAE,GAAG,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,MAAM,CAAC;AAKrD;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC;AAEhC,MAAM,UAAU,SAAS;IACvB,MAAM,GAAG,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;IACzB,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC;IACvC,OAAO,WAAW,CAChB,CAAC,IAAY,EAAQ,EAAE;QACrB,IAAI,OAAO,UAAU,KAAK,UAAU,EAAE,CAAC;YACrC,UAAU,CAAC,IAAI,EAAE,cAAc,CAAC,CAAC;YACjC,OAAO;QACT,CAAC;QACD,uEAAuE;QACvE,uEAAuE;QACvE,wEAAwE;QACxE,IAAI,OAAO,QAAQ,KAAK,WAAW;YAAE,OAAO;QAC5C,aAAa,CAAC,OAAO,CAAC,IAAI,EAAE,cAAc,CAAC,CAAC;IAC9C,CAAC,EACD,CAAC,UAAU,CAAC,CACb,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"notice.js","sourceRoot":"","sources":["../../src/default/notice.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AACvD,OAAO,EAAE,GAAG,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,MAAM,CAAC;AAarD;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC;AAEhC,MAAM,UAAU,SAAS;IACvB,MAAM,GAAG,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;IACzB,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC;IACvC,0EAA0E;IAC1E,yEAAyE;IACzE,kDAAkD;IAClD,MAAM,QAAQ,GAAG,MAAM,CAAoB,IAAI,GAAG,EAAE,CAAC,CAAC;IACtD,SAAS,CACP,GAAG,EAAE,CAAC,GAAG,EAAE;QACT,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC;QAC9B,QAAQ,CAAC,OAAO,GAAG,IAAI,GAAG,EAAE,CAAC;QAC7B,KAAK,MAAM,KAAK,IAAI,IAAI;YAAE,KAAK,EAAE,CAAC;IACpC,CAAC,EACD,EAAE,CACH,CAAC;IACF,MAAM,IAAI,GAAG,WAAW,CAAC,CAAC,MAAoB,EAAQ,EAAE;QACtD,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAC7B,MAAM,MAAM,GAAG,GAAS,EAAE;YACxB,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAClC,CAAC,CAAC;QACF,gEAAgE;QAChE,0EAA0E;QAC1E,mEAAmE;QACnE,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,CAAC,EAAE,EAAE,CAAC,CAAC;IACP,OAAO,WAAW,CAChB,CAAC,IAAY,EAAQ,EAAE;QACrB,IAAI,OAAO,UAAU,KAAK,UAAU,EAAE,CAAC;YACrC,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,cAAc,CAAiB,CAAC,CAAC;YACvD,OAAO;QACT,CAAC;QACD,uEAAuE;QACvE,uEAAuE;QACvE,wEAAwE;QACxE,IAAI,OAAO,QAAQ,KAAK,WAAW;YAAE,OAAO;QAC5C,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,IAAI,EAAE,cAAc,CAAiB,CAAC,CAAC;IACpE,CAAC,EACD,CAAC,UAAU,EAAE,IAAI,CAAC,CACnB,CAAC;AACJ,CAAC"}
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @stapel/listings-react 0.28.0
1
+ # @stapel/listings-react 0.28.1
2
2
 
3
3
  Headless React flow pair for stapel-listings (contract >=0.22 <0.23) — business + state, zero visual opinion.
4
4
  Built on @stapel/core: typed client + StapelApiError envelope, auth token refresh,
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
3
3
  "package": "@stapel/listings-react",
4
- "version": "0.28.0",
4
+ "version": "0.28.1",
5
5
  "backend": {
6
6
  "module": "stapel-listings",
7
7
  "contract": ">=0.22 <0.23"
package/nav-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@stapel/listings-react",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "entries": [
5
5
  {
6
6
  "id": "listings.detail",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/listings-react",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "description": "Headless React pair for stapel-listings: a typed client over the draft twin, the composer that turns a category schema into a submittable listing, and the owner's dashboard. Two independent axes are rendered as two — lifecycle `status` decides visibility and `moderation_status` decides nothing about it, so an edit to a live listing stays live and says 'under review' instead of vanishing. Feature values are drawn and mirrored by @stapel/attributes-react; photos arrive as an injected upload bag whose `refs` ARE `images_draft` and whose `settled` gates the submit; a publish refusal is routed onto the control that caused it by slug. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -103,12 +103,12 @@
103
103
  "typescript": "^5.8.3",
104
104
  "vitest": "^3.2.4",
105
105
  "@stapel/attributes-react": "^0.16.5",
106
- "@stapel/core": "^0.25.4",
106
+ "@stapel/core": "^0.26.0",
107
107
  "@stapel/currencies-react": "^0.4.0",
108
108
  "@stapel/image": "^0.4.2",
109
109
  "@stapel/showcase": "^0.3.0",
110
110
  "@stapel/tokens": "^0.8.0",
111
- "@stapel/tokens-antd": "^0.17.3"
111
+ "@stapel/tokens-antd": "^0.18.0"
112
112
  },
113
113
  "engines": {
114
114
  "node": ">=22"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/listings-react",
4
- "version": "0.28.0",
4
+ "version": "0.28.1",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -30,13 +30,43 @@
30
30
  * the heart's state is on the heart. This is the AMPLIFIER, never the record.
31
31
  * A future `SkinNotice` in the substrate replaces the body of this function
32
32
  * and nothing else; the callers already speak in resolved sentences.
33
+ *
34
+ * ── An amplifier may not outlive what it amplifies ────────────────────────
35
+ *
36
+ * Both arms hand the notice to a holder MOUNTED OUTSIDE this component tree —
37
+ * antd's `<App>` holder in the first arm, and in the second a React root antd
38
+ * renders into the document the first time the static entry is called. Neither
39
+ * is unmounted by unmounting the surface that spoke, and a standing notice is
40
+ * not an idle DOM node: `@rc-component/notification` counts its two seconds
41
+ * down with a `requestAnimationFrame` LOOP (`useNoticeTimer`), which is live
42
+ * work driven from a root nothing in this package owns.
43
+ *
44
+ * So the notices raised here are RETIRED when the surface that raised them
45
+ * goes. Two things follow, and both are the intent:
46
+ *
47
+ * - a person who presses the heart and immediately navigates away does not
48
+ * get "Saved" floating over the next page, about a listing they left;
49
+ * - nothing this pair started keeps running after the tree it started in is
50
+ * gone. Measured as CI flake: the detail page's heart toast kept stepping
51
+ * its rAF loop past the end of the test file that raised it, and the frame
52
+ * that landed after the environment was torn down threw
53
+ * `ReferenceError: window is not defined` out of react-dom — from a suite
54
+ * in which every test had passed.
33
55
  */
34
- import { useCallback } from "react";
56
+ import { useCallback, useEffect, useRef } from "react";
35
57
  import { App, message as staticMessage } from "antd";
36
58
 
37
59
  /** Say one short sentence. Resolved copy — this is the skin, not a bag. */
38
60
  export type Notice = (text: string) => void;
39
61
 
62
+ /**
63
+ * What antd hands back for a raised notice: CALL it to close the notice early,
64
+ * `then` it to learn that it closed on its own. Typed structurally rather than
65
+ * imported (`antd/es/message/interface`) so this file keeps to antd's public
66
+ * entry — the shape is antd's documented `MessageType` either way.
67
+ */
68
+ type RaisedNotice = (() => void) & PromiseLike<unknown>;
69
+
40
70
  /**
41
71
  * How long a confirmation stands, in seconds.
42
72
  *
@@ -51,18 +81,40 @@ export const NOTICE_SECONDS = 2;
51
81
  export function useNotice(): Notice {
52
82
  const app = App.useApp();
53
83
  const contextual = app.message.success;
84
+ // The notices this surface has raised and that have not closed themselves
85
+ // yet — see the header. A notice retires itself the moment it closes, so
86
+ // this holds at most the handful still on screen.
87
+ const standing = useRef<Set<RaisedNotice>>(new Set());
88
+ useEffect(
89
+ () => () => {
90
+ const open = standing.current;
91
+ standing.current = new Set();
92
+ for (const close of open) close();
93
+ },
94
+ []
95
+ );
96
+ const hold = useCallback((raised: RaisedNotice): void => {
97
+ standing.current.add(raised);
98
+ const retire = (): void => {
99
+ standing.current.delete(raised);
100
+ };
101
+ // Closed by its own timer, by our unmount, or by a host calling
102
+ // `message.destroy()` — every ending resolves this, and a rejection is an
103
+ // ending too. Nothing is awaited: the notice is already on screen.
104
+ raised.then(retire, retire);
105
+ }, []);
54
106
  return useCallback(
55
107
  (text: string): void => {
56
108
  if (typeof contextual === "function") {
57
- contextual(text, NOTICE_SECONDS);
109
+ hold(contextual(text, NOTICE_SECONDS) as RaisedNotice);
58
110
  return;
59
111
  }
60
112
  // No `<App>` above us. antd's static entry renders its own holder into
61
113
  // the document, which is exactly right for a host that never opted in,
62
114
  // and is a no-op on a server where there is no document to render into.
63
115
  if (typeof document === "undefined") return;
64
- staticMessage.success(text, NOTICE_SECONDS);
116
+ hold(staticMessage.success(text, NOTICE_SECONDS) as RaisedNotice);
65
117
  },
66
- [contextual]
118
+ [contextual, hold]
67
119
  );
68
120
  }