fix(clients/ts): F-T-001 + F-T-002 flat useEndpointGet / useEndpointPost hooks

useEndpoint(path) returned `{ get: () => useResource(...), post: (body)
=> useResource(...) }`. Each of `.get()` and `.post()` called useState
+ useEffect — but React's Rules of Hooks require hook calls at the top
level of a component, in the same order each render. Calling
.get() conditionally, or both .get() and .post() from the same
component, produced undefined behaviour (state leaking between them).
The test suite covered only useTopic, so this was uncaught.

Separately (F-T-002): the JSDoc said .post() was "the mutation variant
(auto-fires once per mount)" — but auto-firing a POST on mount means
typo'd code creates a user (or sends an email) on every render refresh.

Refactor:
- Remove useEndpoint entirely (public API breaking change — clients/ts
  v1.1.x has no external consumers yet per CLAUDE.md).
- Add useEndpointGet<Res>(path): QueryState<Res> — flat hook, auto-fires.
- Add useEndpointPost<Req, Res>(path): { mutate, data, loading, error }
  — flat hook, event-driven (NOT auto-firing); call `mutate(body)` from
  the submit handler.

README updated to demonstrate both shapes side-by-side.

Existing 15 unit tests pass (useTopic unaffected; useEndpoint tests
never existed). Adding a useEndpointGet/Post test pass is finding
F-T-001 follow-up.

AUDIT.md anchor: F-T-001 (+ F-T-002 folded in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 20:30:26 +02:00
parent 617c216429
commit efb644efe9
3 changed files with 73 additions and 20 deletions

View File

@@ -42,7 +42,12 @@ const token = client.auth.token;
## React
```tsx
import { PicloudProvider, useTopic, useEndpoint } from '@picloud/client/react';
import {
PicloudProvider,
useTopic,
useEndpointGet,
useEndpointPost
} from '@picloud/client/react';
// Wrap your tree once: <PicloudProvider client={client}>…</PicloudProvider>
@@ -52,11 +57,25 @@ function ChatRoom({ roomId }: { roomId: string }) {
}
function UserProfile({ id }: { id: string }) {
const { data, loading, error } = useEndpoint<UserRes>(`/api/users/${id}`).get();
// Auto-fires when `id` changes; conforms to Rules of Hooks.
const { data, loading, error } = useEndpointGet<UserRes>(`/api/users/${id}`);
if (loading) return <Spinner />;
if (error) return <ErrorView error={error} />;
return <div>{data?.name}</div>;
}
function CreateUserForm() {
// Event-driven: NOT fired on mount; call `mutate(body)` on submit.
const { mutate, loading, error } = useEndpointPost<CreateUserReq, CreateUserRes>(
'/api/users'
);
return (
<form onSubmit={(e) => { e.preventDefault(); mutate({ name: 'Alice' }); }}>
<button disabled={loading}>Create</button>
{error ? <ErrorView error={error} /> : null}
</form>
);
}
```
## Svelte

View File

@@ -184,6 +184,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -207,6 +208,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1048,6 +1050,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -1117,6 +1120,7 @@
"integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1685,6 +1689,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -2000,6 +2005,7 @@
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
@@ -2282,6 +2288,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2331,6 +2338,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
@@ -2414,6 +2422,7 @@
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -2427,6 +2436,7 @@
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -2851,6 +2861,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2872,6 +2883,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

View File

@@ -60,26 +60,20 @@ export interface QueryState<T> {
error: unknown;
}
export interface EndpointHook<Req, Res> {
get: () => QueryState<Res>;
post: (body?: Req) => QueryState<Res>;
export interface MutationState<T> {
data: T | null;
loading: boolean;
error: unknown;
}
/**
* Typed endpoint hook. `useEndpoint<Res>(path).get()` fires a GET and
* returns `{ data, loading, error }`, re-running when `path` changes.
* `.post(body)` is the mutation variant (auto-fires once per mount).
* F-T-001: flat GET hook. Replaces `useEndpoint(path).get()` which
* violated the Rules of Hooks (calling `useState`/`useEffect` from
* inside a returned function). Auto-fires once per `path` change and
* re-runs on mount.
*/
export function useEndpoint<Res = unknown, Req = unknown>(path: string): EndpointHook<Req, Res> {
export function useEndpointGet<Res = unknown>(path: string): QueryState<Res> {
const client = usePicloud();
return {
get: () => useResource<Res>(() => client.endpoint<Req, Res>(path).get(), path, 'GET'),
post: (body?: Req) =>
useResource<Res>(() => client.endpoint<Req, Res>(path).post(body), path, 'POST')
};
}
function useResource<Res>(run: () => Promise<Res>, key: string, method: string): QueryState<Res> {
const [state, setState] = useState<QueryState<Res>>({
data: null,
loading: true,
@@ -88,14 +82,42 @@ function useResource<Res>(run: () => Promise<Res>, key: string, method: string):
useEffect(() => {
let active = true;
setState({ data: null, loading: true, error: null });
run()
client
.endpoint<unknown, Res>(path)
.get()
.then((data) => active && setState({ data, loading: false, error: null }))
.catch((error) => active && setState({ data: null, loading: false, error }));
return () => {
active = false;
};
// `run` is recreated each render; key it on path + method instead.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, method]);
}, [client, path]);
return state;
}
/**
* F-T-001 + F-T-002: event-driven POST hook. Returns
* `{ mutate, data, loading, error }` — `mutate(body)` fires the POST.
* Does NOT auto-fire on mount (the previous `useEndpoint(path).post()`
* shape would create a user / send an email on every render-refresh).
*/
export function useEndpointPost<Req = unknown, Res = unknown>(
path: string
): MutationState<Res> & { mutate: (body?: Req) => Promise<void> } {
const client = usePicloud();
const [state, setState] = useState<MutationState<Res>>({
data: null,
loading: false,
error: null
});
const mutate = async (body?: Req) => {
setState({ data: null, loading: true, error: null });
try {
const data = await client.endpoint<Req, Res>(path).post(body);
setState({ data, loading: false, error: null });
} catch (error) {
setState({ data: null, loading: false, error });
}
};
return { ...state, mutate };
}