@open-mercato/ui 0.6.7-develop.6676.1.7dad6df292 → 0.6.7-develop.6685.1.77c0a5591b

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.
@@ -24,32 +24,27 @@ function arraysEqual(a, b) {
24
24
  return true;
25
25
  }
26
26
  function useGroupOrder(pageType, defaultGroupIds) {
27
- const [orderedIds, setOrderedIds] = React.useState(defaultGroupIds);
28
- const mounted = React.useRef(false);
27
+ const [savedOrder, setSavedOrder] = React.useState(null);
29
28
  React.useEffect(() => {
30
- mounted.current = true;
31
29
  const saved = readJsonFromLocalStorage(getStorageKey(pageType), null);
32
- if (Array.isArray(saved)) {
33
- setOrderedIds(mergeOrder(saved, defaultGroupIds));
34
- }
30
+ setSavedOrder(Array.isArray(saved) ? saved : null);
35
31
  }, [pageType]);
36
- React.useEffect(() => {
37
- setOrderedIds((prev) => {
38
- const merged = mergeOrder(prev, defaultGroupIds);
39
- return arraysEqual(prev, merged) ? prev : merged;
40
- });
41
- }, [defaultGroupIds]);
32
+ const mergedIds = React.useMemo(
33
+ () => savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds,
34
+ [defaultGroupIds, savedOrder]
35
+ );
36
+ const stableIdsRef = React.useRef(mergedIds);
37
+ if (!arraysEqual(stableIdsRef.current, mergedIds)) {
38
+ stableIdsRef.current = mergedIds;
39
+ }
40
+ const orderedIds = stableIdsRef.current;
42
41
  const reorder = React.useCallback(
43
42
  (fromIndex, toIndex) => {
44
- setOrderedIds((prev) => {
45
- const next = [...prev];
46
- const [moved] = next.splice(fromIndex, 1);
47
- next.splice(toIndex, 0, moved);
48
- if (mounted.current) {
49
- writeJsonToLocalStorage(getStorageKey(pageType), next);
50
- }
51
- return next;
52
- });
43
+ const next = [...stableIdsRef.current];
44
+ const [moved] = next.splice(fromIndex, 1);
45
+ next.splice(toIndex, 0, moved);
46
+ writeJsonToLocalStorage(getStorageKey(pageType), next);
47
+ setSavedOrder(next);
53
48
  },
54
49
  [pageType]
55
50
  );
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/crud/useGroupOrder.ts"],
4
- "sourcesContent": ["'use client'\nimport * as React from 'react'\nimport {\n readJsonFromLocalStorage,\n writeJsonToLocalStorage,\n} from '@open-mercato/shared/lib/browser/safeLocalStorage'\n\nconst STORAGE_PREFIX = 'om:group-order:'\n\nfunction getStorageKey(pageType: string) {\n return `${STORAGE_PREFIX}${pageType}`\n}\n\nfunction mergeOrder(saved: string[], defaults: string[]): string[] {\n const known = new Set(defaults)\n const result = saved.filter((id) => known.has(id))\n for (const id of defaults) {\n if (!result.includes(id)) result.push(id)\n }\n return result\n}\n\nfunction arraysEqual(a: string[], b: string[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i += 1) {\n if (a[i] !== b[i]) return false\n }\n return true\n}\n\n/**\n * Returns the group IDs in the user's preferred order.\n * Falls back to the default order when no preference is stored.\n */\nexport function useGroupOrder(pageType: string, defaultGroupIds: string[]) {\n const [orderedIds, setOrderedIds] = React.useState<string[]>(defaultGroupIds)\n const mounted = React.useRef(false)\n\n React.useEffect(() => {\n mounted.current = true\n const saved = readJsonFromLocalStorage<string[] | null>(getStorageKey(pageType), null)\n if (Array.isArray(saved)) {\n setOrderedIds(mergeOrder(saved, defaultGroupIds))\n }\n // Intentionally only runs on mount (per pageType); defaultGroupIds changes are\n // handled by the sync effect below.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [pageType])\n\n // Sync when defaultGroupIds changes (e.g. new groups added dynamically)\n React.useEffect(() => {\n setOrderedIds((prev) => {\n const merged = mergeOrder(prev, defaultGroupIds)\n return arraysEqual(prev, merged) ? prev : merged\n })\n }, [defaultGroupIds])\n\n const reorder = React.useCallback(\n (fromIndex: number, toIndex: number) => {\n setOrderedIds((prev) => {\n const next = [...prev]\n const [moved] = next.splice(fromIndex, 1)\n next.splice(toIndex, 0, moved)\n if (mounted.current) {\n writeJsonToLocalStorage(getStorageKey(pageType), next)\n }\n return next\n })\n },\n [pageType],\n )\n\n return { orderedIds, reorder }\n}\n"],
5
- "mappings": ";AACA,YAAY,WAAW;AACvB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAEP,MAAM,iBAAiB;AAEvB,SAAS,cAAc,UAAkB;AACvC,SAAO,GAAG,cAAc,GAAG,QAAQ;AACrC;AAEA,SAAS,WAAW,OAAiB,UAA8B;AACjE,QAAM,QAAQ,IAAI,IAAI,QAAQ;AAC9B,QAAM,SAAS,MAAM,OAAO,CAAC,OAAO,MAAM,IAAI,EAAE,CAAC;AACjD,aAAW,MAAM,UAAU;AACzB,QAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO,KAAK,EAAE;AAAA,EAC1C;AACA,SAAO;AACT;AAEA,SAAS,YAAY,GAAa,GAAsB;AACtD,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK,GAAG;AACpC,QAAI,EAAE,CAAC,MAAM,EAAE,CAAC,EAAG,QAAO;AAAA,EAC5B;AACA,SAAO;AACT;AAMO,SAAS,cAAc,UAAkB,iBAA2B;AACzE,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAAmB,eAAe;AAC5E,QAAM,UAAU,MAAM,OAAO,KAAK;AAElC,QAAM,UAAU,MAAM;AACpB,YAAQ,UAAU;AAClB,UAAM,QAAQ,yBAA0C,cAAc,QAAQ,GAAG,IAAI;AACrF,QAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,oBAAc,WAAW,OAAO,eAAe,CAAC;AAAA,IAClD;AAAA,EAIF,GAAG,CAAC,QAAQ,CAAC;AAGb,QAAM,UAAU,MAAM;AACpB,kBAAc,CAAC,SAAS;AACtB,YAAM,SAAS,WAAW,MAAM,eAAe;AAC/C,aAAO,YAAY,MAAM,MAAM,IAAI,OAAO;AAAA,IAC5C,CAAC;AAAA,EACH,GAAG,CAAC,eAAe,CAAC;AAEpB,QAAM,UAAU,MAAM;AAAA,IACpB,CAAC,WAAmB,YAAoB;AACtC,oBAAc,CAAC,SAAS;AACtB,cAAM,OAAO,CAAC,GAAG,IAAI;AACrB,cAAM,CAAC,KAAK,IAAI,KAAK,OAAO,WAAW,CAAC;AACxC,aAAK,OAAO,SAAS,GAAG,KAAK;AAC7B,YAAI,QAAQ,SAAS;AACnB,kCAAwB,cAAc,QAAQ,GAAG,IAAI;AAAA,QACvD;AACA,eAAO;AAAA,MACT,CAAC;AAAA,IACH;AAAA,IACA,CAAC,QAAQ;AAAA,EACX;AAEA,SAAO,EAAE,YAAY,QAAQ;AAC/B;",
4
+ "sourcesContent": ["'use client'\nimport * as React from 'react'\nimport {\n readJsonFromLocalStorage,\n writeJsonToLocalStorage,\n} from '@open-mercato/shared/lib/browser/safeLocalStorage'\n\nconst STORAGE_PREFIX = 'om:group-order:'\n\nfunction getStorageKey(pageType: string) {\n return `${STORAGE_PREFIX}${pageType}`\n}\n\nfunction mergeOrder(saved: string[], defaults: string[]): string[] {\n const known = new Set(defaults)\n const result = saved.filter((id) => known.has(id))\n for (const id of defaults) {\n if (!result.includes(id)) result.push(id)\n }\n return result\n}\n\nfunction arraysEqual(a: string[], b: string[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i += 1) {\n if (a[i] !== b[i]) return false\n }\n return true\n}\n\n/**\n * Returns the group IDs in the user's preferred order.\n * Falls back to the default order when no preference is stored.\n *\n * State holds only the saved preference; the visible order is derived during\n * render. Syncing derived order back into state via an effect looped forever\n * when a host recreated `defaultGroupIds` with different content on every\n * render (#4386), so no effect writes state from `defaultGroupIds` here.\n */\nexport function useGroupOrder(pageType: string, defaultGroupIds: string[]) {\n const [savedOrder, setSavedOrder] = React.useState<string[] | null>(null)\n\n React.useEffect(() => {\n const saved = readJsonFromLocalStorage<string[] | null>(getStorageKey(pageType), null)\n setSavedOrder(Array.isArray(saved) ? saved : null)\n }, [pageType])\n\n const mergedIds = React.useMemo(\n () => (savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds),\n [defaultGroupIds, savedOrder],\n )\n\n const stableIdsRef = React.useRef(mergedIds)\n if (!arraysEqual(stableIdsRef.current, mergedIds)) {\n stableIdsRef.current = mergedIds\n }\n const orderedIds = stableIdsRef.current\n\n const reorder = React.useCallback(\n (fromIndex: number, toIndex: number) => {\n const next = [...stableIdsRef.current]\n const [moved] = next.splice(fromIndex, 1)\n next.splice(toIndex, 0, moved)\n writeJsonToLocalStorage(getStorageKey(pageType), next)\n setSavedOrder(next)\n },\n [pageType],\n )\n\n return { orderedIds, reorder }\n}\n"],
5
+ "mappings": ";AACA,YAAY,WAAW;AACvB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAEP,MAAM,iBAAiB;AAEvB,SAAS,cAAc,UAAkB;AACvC,SAAO,GAAG,cAAc,GAAG,QAAQ;AACrC;AAEA,SAAS,WAAW,OAAiB,UAA8B;AACjE,QAAM,QAAQ,IAAI,IAAI,QAAQ;AAC9B,QAAM,SAAS,MAAM,OAAO,CAAC,OAAO,MAAM,IAAI,EAAE,CAAC;AACjD,aAAW,MAAM,UAAU;AACzB,QAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO,KAAK,EAAE;AAAA,EAC1C;AACA,SAAO;AACT;AAEA,SAAS,YAAY,GAAa,GAAsB;AACtD,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK,GAAG;AACpC,QAAI,EAAE,CAAC,MAAM,EAAE,CAAC,EAAG,QAAO;AAAA,EAC5B;AACA,SAAO;AACT;AAWO,SAAS,cAAc,UAAkB,iBAA2B;AACzE,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAA0B,IAAI;AAExE,QAAM,UAAU,MAAM;AACpB,UAAM,QAAQ,yBAA0C,cAAc,QAAQ,GAAG,IAAI;AACrF,kBAAc,MAAM,QAAQ,KAAK,IAAI,QAAQ,IAAI;AAAA,EACnD,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,YAAY,MAAM;AAAA,IACtB,MAAO,aAAa,WAAW,YAAY,eAAe,IAAI;AAAA,IAC9D,CAAC,iBAAiB,UAAU;AAAA,EAC9B;AAEA,QAAM,eAAe,MAAM,OAAO,SAAS;AAC3C,MAAI,CAAC,YAAY,aAAa,SAAS,SAAS,GAAG;AACjD,iBAAa,UAAU;AAAA,EACzB;AACA,QAAM,aAAa,aAAa;AAEhC,QAAM,UAAU,MAAM;AAAA,IACpB,CAAC,WAAmB,YAAoB;AACtC,YAAM,OAAO,CAAC,GAAG,aAAa,OAAO;AACrC,YAAM,CAAC,KAAK,IAAI,KAAK,OAAO,WAAW,CAAC;AACxC,WAAK,OAAO,SAAS,GAAG,KAAK;AAC7B,8BAAwB,cAAc,QAAQ,GAAG,IAAI;AACrD,oBAAc,IAAI;AAAA,IACpB;AAAA,IACA,CAAC,QAAQ;AAAA,EACX;AAEA,SAAO,EAAE,YAAY,QAAQ;AAC/B;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.7-develop.6676.1.7dad6df292",
3
+ "version": "0.6.7-develop.6685.1.77c0a5591b",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,13 +155,13 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.7-develop.6676.1.7dad6df292",
158
+ "@open-mercato/shared": "0.6.7-develop.6685.1.77c0a5591b",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
- "@open-mercato/shared": "0.6.7-develop.6676.1.7dad6df292",
164
+ "@open-mercato/shared": "0.6.7-develop.6685.1.77c0a5591b",
165
165
  "@testing-library/dom": "^10.4.1",
166
166
  "@testing-library/jest-dom": "^6.9.1",
167
167
  "@testing-library/react": "^16.3.1",
@@ -1,5 +1,6 @@
1
1
  /** @jest-environment jsdom */
2
- import { act, renderHook } from '@testing-library/react'
2
+ import * as React from 'react'
3
+ import { act, render, renderHook, screen } from '@testing-library/react'
3
4
  import { useGroupOrder } from '../useGroupOrder'
4
5
 
5
6
  describe('useGroupOrder', () => {
@@ -60,4 +61,31 @@ describe('useGroupOrder', () => {
60
61
  expect(spy).not.toHaveBeenCalledWith('om:group-order:p', expect.anything())
61
62
  spy.mockRestore()
62
63
  })
64
+
65
+ it('keeps a stable orderedIds identity when defaults are recreated with equal content', () => {
66
+ const { result, rerender } = renderHook(
67
+ ({ ids }) => useGroupOrder('p', ids),
68
+ { initialProps: { ids: ['a', 'b'] } },
69
+ )
70
+ const first = result.current.orderedIds
71
+ rerender({ ids: ['a', 'b'] })
72
+ expect(result.current.orderedIds).toBe(first)
73
+ })
74
+
75
+ it('does not loop when the host recreates defaults with different content on every render (#4386)', () => {
76
+ localStorage.setItem('om:group-order:unstable', JSON.stringify(['b', 'a']))
77
+ let renders = 0
78
+ function UnstableHost() {
79
+ renders += 1
80
+ if (renders > 50) throw new Error('render loop detected')
81
+ const defaults = renders % 2 === 1 ? ['a', 'b'] : ['a', 'b', `extra-${renders}`]
82
+ const { orderedIds } = useGroupOrder('unstable', defaults)
83
+ return React.createElement('div', { 'data-testid': 'order' }, orderedIds.join(','))
84
+ }
85
+
86
+ render(React.createElement(UnstableHost))
87
+
88
+ expect(renders).toBeLessThanOrEqual(6)
89
+ expect(screen.getByTestId('order').textContent).toContain('b,a')
90
+ })
63
91
  })
@@ -31,41 +31,38 @@ function arraysEqual(a: string[], b: string[]): boolean {
31
31
  /**
32
32
  * Returns the group IDs in the user's preferred order.
33
33
  * Falls back to the default order when no preference is stored.
34
+ *
35
+ * State holds only the saved preference; the visible order is derived during
36
+ * render. Syncing derived order back into state via an effect looped forever
37
+ * when a host recreated `defaultGroupIds` with different content on every
38
+ * render (#4386), so no effect writes state from `defaultGroupIds` here.
34
39
  */
35
40
  export function useGroupOrder(pageType: string, defaultGroupIds: string[]) {
36
- const [orderedIds, setOrderedIds] = React.useState<string[]>(defaultGroupIds)
37
- const mounted = React.useRef(false)
41
+ const [savedOrder, setSavedOrder] = React.useState<string[] | null>(null)
38
42
 
39
43
  React.useEffect(() => {
40
- mounted.current = true
41
44
  const saved = readJsonFromLocalStorage<string[] | null>(getStorageKey(pageType), null)
42
- if (Array.isArray(saved)) {
43
- setOrderedIds(mergeOrder(saved, defaultGroupIds))
44
- }
45
- // Intentionally only runs on mount (per pageType); defaultGroupIds changes are
46
- // handled by the sync effect below.
47
- // eslint-disable-next-line react-hooks/exhaustive-deps
45
+ setSavedOrder(Array.isArray(saved) ? saved : null)
48
46
  }, [pageType])
49
47
 
50
- // Sync when defaultGroupIds changes (e.g. new groups added dynamically)
51
- React.useEffect(() => {
52
- setOrderedIds((prev) => {
53
- const merged = mergeOrder(prev, defaultGroupIds)
54
- return arraysEqual(prev, merged) ? prev : merged
55
- })
56
- }, [defaultGroupIds])
48
+ const mergedIds = React.useMemo(
49
+ () => (savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds),
50
+ [defaultGroupIds, savedOrder],
51
+ )
52
+
53
+ const stableIdsRef = React.useRef(mergedIds)
54
+ if (!arraysEqual(stableIdsRef.current, mergedIds)) {
55
+ stableIdsRef.current = mergedIds
56
+ }
57
+ const orderedIds = stableIdsRef.current
57
58
 
58
59
  const reorder = React.useCallback(
59
60
  (fromIndex: number, toIndex: number) => {
60
- setOrderedIds((prev) => {
61
- const next = [...prev]
62
- const [moved] = next.splice(fromIndex, 1)
63
- next.splice(toIndex, 0, moved)
64
- if (mounted.current) {
65
- writeJsonToLocalStorage(getStorageKey(pageType), next)
66
- }
67
- return next
68
- })
61
+ const next = [...stableIdsRef.current]
62
+ const [moved] = next.splice(fromIndex, 1)
63
+ next.splice(toIndex, 0, moved)
64
+ writeJsonToLocalStorage(getStorageKey(pageType), next)
65
+ setSavedOrder(next)
69
66
  },
70
67
  [pageType],
71
68
  )