@oxyhq/services 22.2.0 → 22.4.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.
Files changed (88) hide show
  1. package/lib/commonjs/index.js +6 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/FollowButton.js +24 -19
  4. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxyAuthChooser.js +74 -94
  6. package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
  7. package/lib/commonjs/ui/components/oxyAuthChooserStyles.js +0 -19
  8. package/lib/commonjs/ui/components/oxyAuthChooserStyles.js.map +1 -1
  9. package/lib/commonjs/ui/context/useOxyAccountGraph.js +2 -0
  10. package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/useFollow.js +53 -10
  12. package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
  13. package/lib/commonjs/ui/hooks/useSessionManagement.js +2 -0
  14. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  15. package/lib/commonjs/ui/index.js +6 -0
  16. package/lib/commonjs/ui/index.js.map +1 -1
  17. package/lib/commonjs/ui/stores/followStore.js +276 -92
  18. package/lib/commonjs/ui/stores/followStore.js.map +1 -1
  19. package/lib/commonjs/ui/stores/resetSessionScopedStores.js +14 -0
  20. package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -0
  21. package/lib/module/index.js +1 -1
  22. package/lib/module/index.js.map +1 -1
  23. package/lib/module/ui/components/FollowButton.js +24 -19
  24. package/lib/module/ui/components/FollowButton.js.map +1 -1
  25. package/lib/module/ui/components/OxyAuthChooser.js +75 -95
  26. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
  27. package/lib/module/ui/components/oxyAuthChooserStyles.js +0 -19
  28. package/lib/module/ui/components/oxyAuthChooserStyles.js.map +1 -1
  29. package/lib/module/ui/context/useOxyAccountGraph.js +2 -0
  30. package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
  31. package/lib/module/ui/hooks/useFollow.js +51 -9
  32. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  33. package/lib/module/ui/hooks/useSessionManagement.js +2 -0
  34. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  35. package/lib/module/ui/index.js +1 -1
  36. package/lib/module/ui/index.js.map +1 -1
  37. package/lib/module/ui/stores/followStore.js +274 -91
  38. package/lib/module/ui/stores/followStore.js.map +1 -1
  39. package/lib/module/ui/stores/resetSessionScopedStores.js +11 -0
  40. package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -0
  41. package/lib/typescript/commonjs/index.d.ts +1 -1
  42. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts +0 -19
  46. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
  49. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +13 -2
  51. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/index.d.ts +1 -1
  54. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/stores/followStore.d.ts +4 -1
  56. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts +3 -0
  58. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
  59. package/lib/typescript/module/index.d.ts +1 -1
  60. package/lib/typescript/module/index.d.ts.map +1 -1
  61. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  62. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
  63. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts +0 -19
  64. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
  65. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  66. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
  67. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  68. package/lib/typescript/module/ui/hooks/useFollow.d.ts +13 -2
  69. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  70. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  71. package/lib/typescript/module/ui/index.d.ts +1 -1
  72. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  73. package/lib/typescript/module/ui/stores/followStore.d.ts +4 -1
  74. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  75. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts +3 -0
  76. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
  77. package/package.json +5 -5
  78. package/src/index.ts +1 -1
  79. package/src/ui/components/FollowButton.tsx +22 -20
  80. package/src/ui/components/OxyAuthChooser.tsx +87 -75
  81. package/src/ui/components/oxyAuthChooserStyles.ts +0 -19
  82. package/src/ui/context/useOxyAccountGraph.ts +2 -0
  83. package/src/ui/hooks/useFollow.ts +51 -9
  84. package/src/ui/hooks/useSessionManagement.ts +2 -0
  85. package/src/ui/index.ts +1 -1
  86. package/src/ui/stores/__tests__/followStore.test.ts +178 -0
  87. package/src/ui/stores/followStore.ts +277 -106
  88. package/src/ui/stores/resetSessionScopedStores.ts +8 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * followStore — tri-state seed, micro-batched resolution, and optimistic
3
+ * mutation tests.
4
+ *
5
+ * Covers the follow-system efficiency overhaul:
6
+ * - `setFollowStatuses` seeds only-if-absent (never clobbers a definite value)
7
+ * - `resolveFollowStatuses` coalesces every UNKNOWN id requested in one tick
8
+ * into a SINGLE `getFollowStatuses` call, and skips known/seeded ids
9
+ * - `toggleFollowUser` writes optimistically BEFORE the await and rolls back on
10
+ * error
11
+ */
12
+
13
+ import type { OxyServices, FollowMutationResult } from '@oxyhq/core';
14
+ import { useFollowStore } from '../followStore';
15
+
16
+ // A microtask + macrotask flush: `resolveFollowStatuses` schedules its bulk call
17
+ // via `queueMicrotask`, and the mocked `getFollowStatuses` resolves on the
18
+ // microtask queue, so a single macrotask turn drains both.
19
+ const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
20
+
21
+ interface FollowServicesMock {
22
+ getFollowStatuses: jest.Mock<Promise<Record<string, boolean>>, [string[]]>;
23
+ followUser: jest.Mock<Promise<FollowMutationResult>, [string]>;
24
+ unfollowUser: jest.Mock<Promise<FollowMutationResult>, [string]>;
25
+ getCurrentUserId: jest.Mock<string | null, []>;
26
+ }
27
+
28
+ const makeServices = (): { mock: FollowServicesMock; services: OxyServices } => {
29
+ const mock: FollowServicesMock = {
30
+ getFollowStatuses: jest.fn(async (ids: string[]) =>
31
+ Object.fromEntries(ids.map((id) => [id, false])),
32
+ ),
33
+ followUser: jest.fn(async () => ({ message: 'ok' })),
34
+ unfollowUser: jest.fn(async () => ({ message: 'ok' })),
35
+ getCurrentUserId: jest.fn(() => 'viewer-1'),
36
+ };
37
+ return { mock, services: mock as unknown as OxyServices };
38
+ };
39
+
40
+ beforeEach(() => {
41
+ useFollowStore.getState().resetFollowState();
42
+ });
43
+
44
+ afterEach(async () => {
45
+ // Drain any pending micro-batch so module-level state never leaks between tests.
46
+ await flush();
47
+ });
48
+
49
+ describe('setFollowStatuses (seed-only-if-absent)', () => {
50
+ it('seeds absent ids and never clobbers an existing definite value', () => {
51
+ const store = useFollowStore.getState();
52
+ store.setFollowStatuses({ u1: true });
53
+ store.setFollowStatuses({ u1: false, u2: true });
54
+
55
+ expect(useFollowStore.getState().followingUsers).toEqual({ u1: true, u2: true });
56
+ });
57
+ });
58
+
59
+ describe('resolveFollowStatuses (micro-batched)', () => {
60
+ it('coalesces every id requested in one tick into a SINGLE call', async () => {
61
+ const { mock, services } = makeServices();
62
+ mock.getFollowStatuses.mockImplementation(async (ids) =>
63
+ Object.fromEntries(ids.map((id) => [id, id === 'u1'])),
64
+ );
65
+
66
+ const store = useFollowStore.getState();
67
+ store.resolveFollowStatuses(['u1', 'u2'], services);
68
+ store.resolveFollowStatuses(['u3'], services);
69
+
70
+ await flush();
71
+
72
+ expect(mock.getFollowStatuses).toHaveBeenCalledTimes(1);
73
+ expect(mock.getFollowStatuses).toHaveBeenCalledWith(['u1', 'u2', 'u3']);
74
+ expect(useFollowStore.getState().followingUsers).toEqual({ u1: true, u2: false, u3: false });
75
+ });
76
+
77
+ it('skips ids whose status is already known/seeded', async () => {
78
+ const { mock, services } = makeServices();
79
+ const store = useFollowStore.getState();
80
+ store.setFollowStatuses({ u1: false }); // seeded → must NOT re-fetch
81
+
82
+ store.resolveFollowStatuses(['u1', 'u2'], services);
83
+ await flush();
84
+
85
+ expect(mock.getFollowStatuses).toHaveBeenCalledTimes(1);
86
+ expect(mock.getFollowStatuses).toHaveBeenCalledWith(['u2']);
87
+ // The seeded false is preserved; u2 resolved.
88
+ expect(useFollowStore.getState().followingUsers).toEqual({ u1: false, u2: false });
89
+ });
90
+
91
+ it('performs NO call when every requested id is already known', async () => {
92
+ const { mock, services } = makeServices();
93
+ const store = useFollowStore.getState();
94
+ store.setFollowStatuses({ u1: true, u2: false });
95
+
96
+ store.resolveFollowStatuses(['u1', 'u2'], services);
97
+ await flush();
98
+
99
+ expect(mock.getFollowStatuses).not.toHaveBeenCalled();
100
+ });
101
+ });
102
+
103
+ describe('toggleFollowUser (optimistic)', () => {
104
+ it('writes the new value BEFORE the await and reconciles on success', async () => {
105
+ const { mock, services } = makeServices();
106
+ let resolveFollow: (value: FollowMutationResult) => void = () => {};
107
+ mock.followUser.mockReturnValueOnce(
108
+ new Promise<FollowMutationResult>((resolve) => { resolveFollow = resolve; }),
109
+ );
110
+
111
+ const store = useFollowStore.getState();
112
+ const pending = store.toggleFollowUser('u1', services, false);
113
+
114
+ // Optimistic write happened synchronously, before the network resolved.
115
+ expect(useFollowStore.getState().followingUsers.u1).toBe(true);
116
+ expect(useFollowStore.getState().loadingUsers.u1).toBe(true);
117
+
118
+ resolveFollow({ message: 'ok', counts: { followers: 10, following: 3 } });
119
+ await pending;
120
+
121
+ expect(useFollowStore.getState().followingUsers.u1).toBe(true);
122
+ expect(useFollowStore.getState().loadingUsers.u1).toBe(false);
123
+ // Counts from the response were applied.
124
+ expect(useFollowStore.getState().followerCounts.u1).toBe(10);
125
+ expect(useFollowStore.getState().followingCounts['viewer-1']).toBe(3);
126
+ });
127
+
128
+ it('rolls back to the prior definite value on error', async () => {
129
+ const { mock, services } = makeServices();
130
+ mock.followUser.mockRejectedValueOnce(new Error('boom'));
131
+
132
+ const store = useFollowStore.getState();
133
+ store.setFollowStatuses({ u1: false });
134
+
135
+ await store.toggleFollowUser('u1', services, false);
136
+
137
+ // Optimistic true was rolled back to the seeded false.
138
+ expect(useFollowStore.getState().followingUsers.u1).toBe(false);
139
+ expect(useFollowStore.getState().loadingUsers.u1).toBe(false);
140
+ expect(useFollowStore.getState().errors.u1).toBe('boom');
141
+ });
142
+
143
+ it('rolls back to UNKNOWN when there was no prior value', async () => {
144
+ const { mock, services } = makeServices();
145
+ mock.followUser.mockRejectedValueOnce(new Error('nope'));
146
+
147
+ const store = useFollowStore.getState();
148
+ await store.toggleFollowUser('u1', services, false);
149
+
150
+ // The key is removed → back to UNKNOWN (tri-state), not a definite false.
151
+ expect(Object.prototype.hasOwnProperty.call(useFollowStore.getState().followingUsers, 'u1')).toBe(false);
152
+ });
153
+ });
154
+
155
+ describe('resetFollowState', () => {
156
+ it('clears store data and in-flight micro-batch coordination', async () => {
157
+ const { mock, services } = makeServices();
158
+ let resolveBatch: ((value: Record<string, boolean>) => void) | undefined;
159
+ mock.getFollowStatuses.mockImplementation(
160
+ () => new Promise<Record<string, boolean>>((resolve) => { resolveBatch = resolve; }),
161
+ );
162
+
163
+ const store = useFollowStore.getState();
164
+ store.resolveFollowStatuses(['u1'], services);
165
+ store.setFollowingStatus('u2', true);
166
+
167
+ store.resetFollowState();
168
+
169
+ expect(useFollowStore.getState().followingUsers).toEqual({});
170
+ expect(useFollowStore.getState().fetchingUsers).toEqual({});
171
+
172
+ resolveBatch?.({ u1: true });
173
+ await flush();
174
+
175
+ // A late batch must not repopulate a store that was reset.
176
+ expect(useFollowStore.getState().followingUsers).toEqual({});
177
+ });
178
+ });