@micha.bigler/ui-core-micha 2.10.0 → 2.10.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/WORK_ORDERS.md CHANGED
@@ -9,6 +9,7 @@ rules are defined centrally in `webapps/AGENTS.md` → "Work-Order Register".
9
9
  | Prefix | Workstream |
10
10
  |---|---|
11
11
  | `ONB-*` | Onboarding wizard (steps, conditions, persistence) |
12
+ | `PERF-*` | Performance improvements |
12
13
 
13
14
  Introduce a new prefix when none fits and add it here. New WOs always get a
14
15
  prefixed ID; never reuse a bare flat number across workstreams.
@@ -18,3 +19,4 @@ prefixed ID; never reuse a bare flat number across workstreams.
18
19
  | ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
19
20
  |---|---|---|---|---|---|---|
20
21
  | ONB-1 | Per-app configurable notifications onboarding step | New `browserPush` prop `{nagUntil, showOnce}` on `OnboardingProvider`; parameterizes the `browser_push` descriptor's condition (default changes from implicit "all-channels" to "any-channel", stopping the over-nag); `showOnce` via a persisted `onboarding_seen` set (frozen-at-mount ref, no mid-session flicker) | 2026-07-17 | done | 1ae7c20 | Default behavior change affects cockpit and all consumers on their next ucm bump. jg-ferien companion WO pins the new version with `browserPush={{nagUntil: 'any-channel'}}` explicit (matches new default, but pinned explicitly per the WO). |
22
+ | PERF-3B1 | Parallel auth bootstrap after CSRF | Starts auth-methods and current-user concurrently once CSRF is available, while preserving error handling and loading semantics. | 2026-07-19 | done | | Independent `reviewer` + `sec_reviewer` passes both clean (no findings); one P3 test-coverage gap from the reviewer (missing mirror case: auth-methods rejects, current-user succeeds) closed with an added regression test — 45/45 tests green. Published as 2.10.1 (patch, no interface change) and pinned in jg-ferien alongside its PERF-3A companion WO. |
@@ -37,18 +37,24 @@ export const AuthProvider = ({ children }) => {
37
37
  try {
38
38
  // 1) Ensure CSRF cookie exists using the specific client
39
39
  await ensureCsrfToken();
40
- // 2) Load auth methods (public)
41
- try {
42
- const methods = await fetchAuthMethods();
40
+ // 2) Both calls depend on CSRF, but not on each other. Await both settlements
41
+ // so loading stays true until the complete auth bootstrap has finished.
42
+ const [methodsResult, userResult] = await Promise.allSettled([
43
+ fetchAuthMethods(),
44
+ fetchCurrentUser(),
45
+ ]);
46
+ // Keep defaults when the public auth-methods request fails.
47
+ if (methodsResult.status === 'fulfilled') {
48
+ const methods = methodsResult.value;
43
49
  if (isMounted && methods && typeof methods === 'object') {
44
50
  setAuthMethods((prev) => (Object.assign(Object.assign({}, prev), methods)));
45
51
  }
46
52
  }
47
- catch (_a) {
48
- // Keep defaults; login UI remains usable.
53
+ // Preserve the previous unauthenticated/error handling for current-user.
54
+ if (userResult.status === 'rejected') {
55
+ throw userResult.reason;
49
56
  }
50
- // 3) Load user
51
- const data = await fetchCurrentUser();
57
+ const data = userResult.value;
52
58
  if (isMounted) {
53
59
  setUser(mapUserFromApi(data));
54
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.10.0",
3
+ "version": "2.10.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -79,19 +79,27 @@ export const AuthProvider = ({ children }) => {
79
79
  // 1) Ensure CSRF cookie exists using the specific client
80
80
  await ensureCsrfToken();
81
81
 
82
- // 2) Load auth methods (public)
83
- try {
84
- const methods = await fetchAuthMethods();
82
+ // 2) Both calls depend on CSRF, but not on each other. Await both settlements
83
+ // so loading stays true until the complete auth bootstrap has finished.
84
+ const [methodsResult, userResult] = await Promise.allSettled([
85
+ fetchAuthMethods(),
86
+ fetchCurrentUser(),
87
+ ]);
88
+
89
+ // Keep defaults when the public auth-methods request fails.
90
+ if (methodsResult.status === 'fulfilled') {
91
+ const methods = methodsResult.value;
85
92
  if (isMounted && methods && typeof methods === 'object') {
86
93
  setAuthMethods((prev) => ({ ...prev, ...methods }));
87
94
  }
88
- } catch {
89
- // Keep defaults; login UI remains usable.
90
95
  }
91
96
 
92
- // 3) Load user
93
- const data = await fetchCurrentUser();
94
-
97
+ // Preserve the previous unauthenticated/error handling for current-user.
98
+ if (userResult.status === 'rejected') {
99
+ throw userResult.reason;
100
+ }
101
+
102
+ const data = userResult.value;
95
103
  if (isMounted) {
96
104
  setUser(mapUserFromApi(data));
97
105
  }
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import React, { useContext } from 'react';
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
- import { render, screen, waitFor } from '@testing-library/react';
4
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
5
5
 
6
6
  const apiClient = vi.hoisted(() => ({
7
7
  ensureCsrfToken: vi.fn().mockResolvedValue(undefined),
@@ -19,17 +19,19 @@ vi.mock('../src/auth/ReauthModal', () => ({ ReauthModal: () => null }));
19
19
  import { AuthContext, AuthProvider } from '../src/auth/AuthContext';
20
20
 
21
21
  function Probe() {
22
- const { user, refreshUser } = useContext(AuthContext);
22
+ const { user, loading, refreshUser } = useContext(AuthContext);
23
23
  return (
24
24
  <div>
25
25
  <span data-testid="first-name">{user?.first_name || ''}</span>
26
+ <span data-testid="loading">{String(loading)}</span>
26
27
  <button type="button" onClick={() => refreshUser()}>refresh</button>
27
28
  </div>
28
29
  );
29
30
  }
30
31
 
31
- describe('AuthContext refreshUser', () => {
32
+ describe('AuthContext', () => {
32
33
  beforeEach(() => {
34
+ cleanup();
33
35
  vi.clearAllMocks();
34
36
  });
35
37
 
@@ -47,4 +49,81 @@ describe('AuthContext refreshUser', () => {
47
49
  await waitFor(() => expect(screen.getByTestId('first-name').textContent).toBe('Grace'));
48
50
  expect(authApi.fetchCurrentUser).toHaveBeenCalledTimes(2);
49
51
  });
52
+
53
+ it('starts auth methods and current user together after CSRF, then waits for both', async () => {
54
+ let resolveCsrf;
55
+ let resolveMethods;
56
+ let resolveUser;
57
+ const callOrder = [];
58
+
59
+ apiClient.ensureCsrfToken.mockImplementation(() => new Promise((resolve) => {
60
+ resolveCsrf = resolve;
61
+ }));
62
+ authApi.fetchAuthMethods.mockImplementation(() => {
63
+ callOrder.push('auth-methods');
64
+ return new Promise((resolve) => {
65
+ resolveMethods = resolve;
66
+ });
67
+ });
68
+ authApi.fetchCurrentUser.mockImplementation(() => {
69
+ callOrder.push('current-user');
70
+ return new Promise((resolve) => {
71
+ resolveUser = resolve;
72
+ });
73
+ });
74
+
75
+ render(<AuthProvider><Probe /></AuthProvider>);
76
+
77
+ expect(callOrder).toEqual([]);
78
+ resolveCsrf();
79
+
80
+ await waitFor(() => expect(callOrder).toEqual(['auth-methods', 'current-user']));
81
+ expect(screen.getByTestId('loading').textContent).toBe('true');
82
+
83
+ resolveUser({ id: 1, first_name: 'Ada' });
84
+ await Promise.resolve();
85
+ expect(screen.getByTestId('loading').textContent).toBe('true');
86
+
87
+ resolveMethods({ password_login: false });
88
+ await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'));
89
+ expect(screen.getByTestId('first-name').textContent).toBe('Ada');
90
+ });
91
+
92
+ it('still sets the user when auth-methods fails but current-user succeeds', async () => {
93
+ let resolveUser;
94
+
95
+ apiClient.ensureCsrfToken.mockResolvedValue(undefined);
96
+ authApi.fetchAuthMethods.mockRejectedValue(new Error('auth-methods unavailable'));
97
+ authApi.fetchCurrentUser.mockImplementation(() => new Promise((resolve) => {
98
+ resolveUser = resolve;
99
+ }));
100
+
101
+ render(<AuthProvider><Probe /></AuthProvider>);
102
+
103
+ await waitFor(() => expect(authApi.fetchAuthMethods).toHaveBeenCalledTimes(1));
104
+ expect(screen.getByTestId('loading').textContent).toBe('true');
105
+
106
+ resolveUser({ id: 1, first_name: 'Ada' });
107
+ await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'));
108
+ expect(screen.getByTestId('first-name').textContent).toBe('Ada');
109
+ });
110
+
111
+ it('preserves the unauthenticated state after a current-user failure', async () => {
112
+ let resolveMethods;
113
+
114
+ apiClient.ensureCsrfToken.mockResolvedValue(undefined);
115
+ authApi.fetchAuthMethods.mockImplementation(() => new Promise((resolve) => {
116
+ resolveMethods = resolve;
117
+ }));
118
+ authApi.fetchCurrentUser.mockRejectedValue(new Error('Unauthenticated'));
119
+
120
+ render(<AuthProvider><Probe /></AuthProvider>);
121
+
122
+ await waitFor(() => expect(authApi.fetchCurrentUser).toHaveBeenCalledTimes(1));
123
+ expect(screen.getByTestId('loading').textContent).toBe('true');
124
+
125
+ resolveMethods({ password_login: false });
126
+ await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'));
127
+ expect(screen.getByTestId('first-name').textContent).toBe('');
128
+ });
50
129
  });