@yackey-labs/yauth-ui-solidjs 0.1.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.
- package/dist/index.js +909 -0
- package/package.json +35 -0
- package/src/components/change-password-form.tsx +140 -0
- package/src/components/consent-screen.tsx +152 -0
- package/src/components/forgot-password-form.tsx +83 -0
- package/src/components/login-form.tsx +128 -0
- package/src/components/magic-link-form.tsx +84 -0
- package/src/components/mfa-challenge.tsx +83 -0
- package/src/components/mfa-setup.tsx +158 -0
- package/src/components/oauth-buttons.tsx +30 -0
- package/src/components/passkey-button.tsx +98 -0
- package/src/components/profile-settings.tsx +406 -0
- package/src/components/register-form.tsx +119 -0
- package/src/components/reset-password-form.tsx +83 -0
- package/src/components/verify-email.tsx +54 -0
- package/src/index.ts +14 -0
- package/src/provider.test.tsx +159 -0
- package/src/provider.tsx +73 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proves the refetch race condition:
|
|
3
|
+
*
|
|
4
|
+
* The old provider resolved the external Promise (via resolveRefetch)
|
|
5
|
+
* INSIDE the resource fetcher, before `return user`. That means the
|
|
6
|
+
* caller's `await refetch()` resumes before SolidJS has processed
|
|
7
|
+
* the return value and updated the reactive signal.
|
|
8
|
+
*
|
|
9
|
+
* Timeline with the bug:
|
|
10
|
+
* 1. fetcher: resolveRefetch(user) → external Promise resolves
|
|
11
|
+
* 2. fetcher: return user → SolidJS queues signal update
|
|
12
|
+
* 3. caller: navigate("/") → runs BEFORE signal updates
|
|
13
|
+
* 4. ProtectedRoute: user() === null → bounces to /login
|
|
14
|
+
*
|
|
15
|
+
* The fix uses createEffect to resolve only after the resource signal
|
|
16
|
+
* has been updated by SolidJS.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, it } from "bun:test";
|
|
19
|
+
import { createEffect, createResource, createRoot } from "solid-js";
|
|
20
|
+
|
|
21
|
+
// Minimal AuthUser shape for testing
|
|
22
|
+
type AuthUser = { id: string; email: string };
|
|
23
|
+
|
|
24
|
+
const testUser: AuthUser = { id: "u1", email: "test@test.com" };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reproduces the BROKEN pattern: resolveRefetch inside the fetcher.
|
|
28
|
+
* The external Promise resolves before the resource signal updates.
|
|
29
|
+
*/
|
|
30
|
+
function createBrokenProvider(getSessionFn: () => Promise<AuthUser | null>) {
|
|
31
|
+
let resolveRefetch: ((user: AuthUser | null) => void) | null = null;
|
|
32
|
+
|
|
33
|
+
const [session, { refetch }] = createResource(async () => {
|
|
34
|
+
const user = await getSessionFn();
|
|
35
|
+
if (resolveRefetch) {
|
|
36
|
+
resolveRefetch(user);
|
|
37
|
+
resolveRefetch = null;
|
|
38
|
+
}
|
|
39
|
+
return user;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const refetchAsync = (): Promise<AuthUser | null> => {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
resolveRefetch = resolve;
|
|
45
|
+
refetch();
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
user: () => session() ?? null,
|
|
51
|
+
loading: () => session.loading,
|
|
52
|
+
refetchAsync,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The FIXED pattern: resolve via createEffect so we only resolve
|
|
58
|
+
* after SolidJS has updated the resource signal.
|
|
59
|
+
*/
|
|
60
|
+
function createFixedProvider(getSessionFn: () => Promise<AuthUser | null>) {
|
|
61
|
+
let resolveRefetch: ((user: AuthUser | null) => void) | null = null;
|
|
62
|
+
|
|
63
|
+
const [session, { refetch }] = createResource(async () => {
|
|
64
|
+
try {
|
|
65
|
+
return await getSessionFn();
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Resolve pending refetch promises only after the resource signal updates
|
|
72
|
+
createEffect(() => {
|
|
73
|
+
const loading = session.loading;
|
|
74
|
+
if (!loading && resolveRefetch) {
|
|
75
|
+
const resolve = resolveRefetch;
|
|
76
|
+
resolveRefetch = null;
|
|
77
|
+
resolve(session() ?? null);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const refetchAsync = (): Promise<AuthUser | null> => {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
resolveRefetch = resolve;
|
|
84
|
+
refetch();
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
user: () => session() ?? null,
|
|
90
|
+
loading: () => session.loading,
|
|
91
|
+
refetchAsync,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("provider refetch timing", () => {
|
|
96
|
+
it("BROKEN: signal is null when refetchAsync resolves", async () => {
|
|
97
|
+
let callCount = 0;
|
|
98
|
+
const getSession = async () => {
|
|
99
|
+
callCount++;
|
|
100
|
+
// First call returns null (initial load), second returns user (after login)
|
|
101
|
+
return callCount === 1 ? null : testUser;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const result = await new Promise<{
|
|
105
|
+
promiseUser: AuthUser | null;
|
|
106
|
+
signalUser: AuthUser | null;
|
|
107
|
+
}>((done) => {
|
|
108
|
+
createRoot(async (dispose) => {
|
|
109
|
+
const provider = createBrokenProvider(getSession);
|
|
110
|
+
|
|
111
|
+
// Wait for initial resource load
|
|
112
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
113
|
+
|
|
114
|
+
// Simulate login → refetch
|
|
115
|
+
const promiseUser = await provider.refetchAsync();
|
|
116
|
+
const signalUser = provider.user();
|
|
117
|
+
|
|
118
|
+
done({ promiseUser, signalUser });
|
|
119
|
+
dispose();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// The Promise resolved with the user...
|
|
124
|
+
expect(result.promiseUser).toEqual(testUser);
|
|
125
|
+
// ...but the reactive signal is STILL NULL — this is the bug
|
|
126
|
+
expect(result.signalUser).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("FIXED: signal is updated when refetchAsync resolves", async () => {
|
|
130
|
+
let callCount = 0;
|
|
131
|
+
const getSession = async () => {
|
|
132
|
+
callCount++;
|
|
133
|
+
return callCount === 1 ? null : testUser;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const result = await new Promise<{
|
|
137
|
+
promiseUser: AuthUser | null;
|
|
138
|
+
signalUser: AuthUser | null;
|
|
139
|
+
}>((done) => {
|
|
140
|
+
createRoot(async (dispose) => {
|
|
141
|
+
const provider = createFixedProvider(getSession);
|
|
142
|
+
|
|
143
|
+
// Wait for initial resource load
|
|
144
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
145
|
+
|
|
146
|
+
// Simulate login → refetch
|
|
147
|
+
const promiseUser = await provider.refetchAsync();
|
|
148
|
+
const signalUser = provider.user();
|
|
149
|
+
|
|
150
|
+
done({ promiseUser, signalUser });
|
|
151
|
+
dispose();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Both the Promise AND the reactive signal have the user
|
|
156
|
+
expect(result.promiseUser).toEqual(testUser);
|
|
157
|
+
expect(result.signalUser).toEqual(testUser);
|
|
158
|
+
});
|
|
159
|
+
});
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { YAuthClient } from "@yackey-labs/yauth-client";
|
|
2
|
+
import type { AuthUser } from "@yackey-labs/yauth-shared";
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
createEffect,
|
|
6
|
+
createResource,
|
|
7
|
+
type ParentComponent,
|
|
8
|
+
useContext,
|
|
9
|
+
} from "solid-js";
|
|
10
|
+
|
|
11
|
+
interface YAuthContextValue {
|
|
12
|
+
client: YAuthClient;
|
|
13
|
+
user: () => AuthUser | null | undefined;
|
|
14
|
+
loading: () => boolean;
|
|
15
|
+
refetch: () => Promise<AuthUser | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const YAuthContext = createContext<YAuthContextValue>();
|
|
19
|
+
|
|
20
|
+
export const YAuthProvider: ParentComponent<{ client: YAuthClient }> = (
|
|
21
|
+
props,
|
|
22
|
+
) => {
|
|
23
|
+
let resolveRefetch: ((user: AuthUser | null) => void) | null = null;
|
|
24
|
+
|
|
25
|
+
const [session, { refetch }] = createResource(async () => {
|
|
26
|
+
try {
|
|
27
|
+
const result = await props.client.getSession();
|
|
28
|
+
return result.user;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Resolve pending refetch promises only after the resource signal
|
|
35
|
+
// has been updated by SolidJS. Resolving inside the fetcher (before
|
|
36
|
+
// `return`) causes a race: the caller resumes before session() updates.
|
|
37
|
+
createEffect(() => {
|
|
38
|
+
const loading = session.loading;
|
|
39
|
+
if (!loading && resolveRefetch) {
|
|
40
|
+
const resolve = resolveRefetch;
|
|
41
|
+
resolveRefetch = null;
|
|
42
|
+
resolve(session() ?? null);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const refetchAsync = (): Promise<AuthUser | null> => {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
resolveRefetch = resolve;
|
|
49
|
+
refetch();
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<YAuthContext.Provider
|
|
55
|
+
value={{
|
|
56
|
+
client: props.client,
|
|
57
|
+
user: () => session(),
|
|
58
|
+
loading: () => session.loading,
|
|
59
|
+
refetch: refetchAsync,
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{props.children}
|
|
63
|
+
</YAuthContext.Provider>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function useYAuth(): YAuthContextValue {
|
|
68
|
+
const ctx = useContext(YAuthContext);
|
|
69
|
+
if (!ctx) {
|
|
70
|
+
throw new Error("useYAuth must be used within a <YAuthProvider>");
|
|
71
|
+
}
|
|
72
|
+
return ctx;
|
|
73
|
+
}
|