From efb644efe936a57727ddb50c72a6344a90b1eca4 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 7 Jun 2026 20:30:26 +0200 Subject: [PATCH] fix(clients/ts): F-T-001 + F-T-002 flat useEndpointGet / useEndpointPost hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(path): QueryState — flat hook, auto-fires. - Add useEndpointPost(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) --- clients/typescript/README.md | 23 ++++++++++- clients/typescript/package-lock.json | 12 ++++++ clients/typescript/src/react/index.ts | 58 ++++++++++++++++++--------- 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/clients/typescript/README.md b/clients/typescript/README.md index 9aec225..da6b8b2 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -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: @@ -52,11 +57,25 @@ function ChatRoom({ roomId }: { roomId: string }) { } function UserProfile({ id }: { id: string }) { - const { data, loading, error } = useEndpoint(`/api/users/${id}`).get(); + // Auto-fires when `id` changes; conforms to Rules of Hooks. + const { data, loading, error } = useEndpointGet(`/api/users/${id}`); if (loading) return ; if (error) return ; return
{data?.name}
; } + +function CreateUserForm() { + // Event-driven: NOT fired on mount; call `mutate(body)` on submit. + const { mutate, loading, error } = useEndpointPost( + '/api/users' + ); + return ( +
{ e.preventDefault(); mutate({ name: 'Alice' }); }}> + + {error ? : null} + + ); +} ``` ## Svelte diff --git a/clients/typescript/package-lock.json b/clients/typescript/package-lock.json index 92cec06..ea4ea61 100644 --- a/clients/typescript/package-lock.json +++ b/clients/typescript/package-lock.json @@ -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", diff --git a/clients/typescript/src/react/index.ts b/clients/typescript/src/react/index.ts index 0ae8026..797b458 100644 --- a/clients/typescript/src/react/index.ts +++ b/clients/typescript/src/react/index.ts @@ -60,26 +60,20 @@ export interface QueryState { error: unknown; } -export interface EndpointHook { - get: () => QueryState; - post: (body?: Req) => QueryState; +export interface MutationState { + data: T | null; + loading: boolean; + error: unknown; } /** - * Typed endpoint hook. `useEndpoint(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(path: string): EndpointHook { +export function useEndpointGet(path: string): QueryState { const client = usePicloud(); - return { - get: () => useResource(() => client.endpoint(path).get(), path, 'GET'), - post: (body?: Req) => - useResource(() => client.endpoint(path).post(body), path, 'POST') - }; -} - -function useResource(run: () => Promise, key: string, method: string): QueryState { const [state, setState] = useState>({ data: null, loading: true, @@ -88,14 +82,42 @@ function useResource(run: () => Promise, key: string, method: string): useEffect(() => { let active = true; setState({ data: null, loading: true, error: null }); - run() + client + .endpoint(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( + path: string +): MutationState & { mutate: (body?: Req) => Promise } { + const client = usePicloud(); + const [state, setState] = useState>({ + data: null, + loading: false, + error: null + }); + const mutate = async (body?: Req) => { + setState({ data: null, loading: true, error: null }); + try { + const data = await client.endpoint(path).post(body); + setState({ data, loading: false, error: null }); + } catch (error) { + setState({ data: null, loading: false, error }); + } + }; + return { ...state, mutate }; +}