From 4cfb119a1b3d813901be1df57d9136d92840ccca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Sun, 12 Apr 2026 14:08:02 +0200 Subject: [PATCH] fix: resolve high-severity audit issues in core query and React bindings - Fix fresh option falling back to constructor arg instead of instanceFresh - Fix fresh:true returning pending dedup promise instead of forcing new fetch - Fix next()/stream()/sequence() broken for plain object keys - Fix stale data displayed on key change in useQueryBasic - Fix shadowed generic in useQueryActions.localMutate - Document React Compiler setup and rules in AGENTS.md - Add @babel/core as explicit devDependency (peer dep of @rolldown/plugin-babel) - Add test coverage for useQueryBasic, useQueryActions, and useQueryStatus hooks --- AGENTS.md | 19 ++++ package-lock.json | 5 +- package.json | 1 + src/query/query.test.ts | 70 ++++++++++++ src/query/query.ts | 36 +++++-- src/react/hooks/useQueryActions.test.tsx | 119 ++++++++++++++++++++ src/react/hooks/useQueryActions.ts | 2 +- src/react/hooks/useQueryBasic.test.tsx | 131 +++++++++++++++++++++++ src/react/hooks/useQueryBasic.ts | 11 +- src/react/hooks/useQueryStatus.test.tsx | 74 +++++++++++++ 10 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 src/react/hooks/useQueryActions.test.tsx create mode 100644 src/react/hooks/useQueryBasic.test.tsx create mode 100644 src/react/hooks/useQueryStatus.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 9259c77..78f41d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,6 +169,25 @@ describe.concurrent('feature', function () { - `react-in-jsx-scope` rule disabled (using new JSX transform) - Use `// oxlint-disable-next-line` to disable rules inline +## React Compiler + +This project uses [React Compiler](https://react.dev/learn/react-compiler) (`babel-plugin-react-compiler` v1.0.0+) to automatically optimize React components at build time. + +### Build Configuration + +- Runs via `@rolldown/plugin-babel` with `reactCompilerPreset()` exported from `@vitejs/plugin-react` +- Applied only to `src/react/**/*.tsx` files +- Plugin order in `vite.config.ts`: `react()` (JSX transform) then `babel()` (compiler) — this is the [officially recommended order](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md#react-compiler) +- Peer dependencies: `@babel/core`, `@rolldown/plugin-babel`, `babel-plugin-react-compiler` + +### Rules for React Code + +- **Do NOT** use `useMemo`, `useCallback`, or `React.memo` — the compiler handles memoization automatically +- **Do NOT** mutate props, state, or values returned from hooks — the compiler assumes immutability +- All React code must strictly follow the [Rules of React](https://react.dev/reference/rules) +- `useEffectEvent` is used for stable event handler references that should not be listed as effect dependencies +- Prefer `function` declarations for components (not arrow functions assigned to variables) + ## Commit Conventions This project uses [Conventional Commits](https://www.conventionalcommits.org/) and git-cliff for automated changelog generation and version bumping. diff --git a/package-lock.json b/package-lock.json index ddee061..a7b7ce6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.5.7", "license": "MIT", "devDependencies": { + "@babel/core": "^7.29.0", "@rolldown/plugin-babel": "^0.2.2", "@types/node": "^25.5.0", "@types/react": "19.2.14", @@ -33,8 +34,8 @@ "node": ">=18.0.0" }, "peerDependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", "solid-js": "^1.9.0" }, "peerDependenciesMeta": { diff --git a/package.json b/package.json index 47eaa56..a68e717 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "prepack": "npm run build" }, "devDependencies": { + "@babel/core": "^7.29.0", "@rolldown/plugin-babel": "^0.2.2", "@types/node": "^25.5.0", "@types/react": "19.2.14", diff --git a/src/query/query.test.ts b/src/query/query.test.ts index 522653e..9183916 100644 --- a/src/query/query.test.ts +++ b/src/query/query.test.ts @@ -808,4 +808,74 @@ describe.concurrent('query', function () { expect(items).toBe(items) expect(resolvers).toBe(resolvers) }) + + it('respects fresh option from configure()', async ({ expect }) => { + let times = 0 + + function fetcher() { + times++ + return Promise.resolve('value') + } + + const { query, configure } = createQuery({ fetcher, expiration: () => 10000 }) + + await query('key') + expect(times).toBe(1) + + configure({ fresh: true }) + + await query('key') + expect(times).toBe(2) + }) + + it('fresh option aborts in-flight request and starts new fetch', async ({ expect }) => { + let fetchCount = 0 + + function fetcher() { + fetchCount++ + return Promise.resolve('value-' + fetchCount) + } + + const { query } = createQuery({ fetcher, expiration: () => 10000 }) + + await query('key') + expect(fetchCount).toBe(1) + + const result = await query('key', { fresh: true }) + expect(fetchCount).toBe(2) + expect(result).toBe('value-2') + }) + + it('can use next() with object keys', async ({ expect }) => { + function fetcher(key: string) { + return Promise.resolve(key) + } + + const { query, next } = createQuery({ fetcher }) + + const promise = next<{ a: string; b: string }>({ a: '/foo', b: '/bar' }) + + await Promise.all([query('/foo'), query('/bar')]) + + const result = await promise + + expect(result.a).toBe('/foo') + expect(result.b).toBe('/bar') + }) + + it('can use next() with a single string key', async ({ expect }) => { + function fetcher(key: string) { + return Promise.resolve(key) + } + + const { query, next } = createQuery({ fetcher }) + + const promise = next('/foo') + + await query('/foo') + + const result = await promise + + expect(result).toBe('/foo') + }) }) diff --git a/src/query/query.ts b/src/query/query.ts index fc6f4b5..6652f4d 100644 --- a/src/query/query.ts +++ b/src/query/query.ts @@ -342,7 +342,7 @@ export function createQuery(instanceOptions?: Configuration): Query { * Determines if the result should be a fresh fetched * instance regardless of any cached value or its expiration time. */ - const fresh = options?.fresh ?? instanceOptions?.fresh + const fresh = options?.fresh ?? instanceFresh // Force fetching of the data. function refetch(key: string): Promise { @@ -415,8 +415,11 @@ export function createQuery(instanceOptions?: Configuration): Query { } // We want to force a fresh item ignoring any current cached - // value or its expiration time. + // value or its expiration time. Abort any existing in-flight + // request so that refetch starts a genuinely new fetch instead + // of returning the pending deduplication promise. if (fresh) { + abort(key) return refetch(key) } @@ -498,16 +501,33 @@ export function createQuery(instanceOptions?: Configuration): Query { * Waits for the next refetching event on one or more keys and returns * the resolved values. Useful for synchronizing with query updates. * - * @param keys - A single key or an object mapping property names to keys. + * Supports a single key (returns a single value), an array of keys + * (returns an array of values), or an object mapping property names + * to keys (returns an object with the same shape). + * + * @param keys - A single key, array of keys, or object mapping names to keys. * @returns A promise that resolves with the fetched value(s). */ async function next(keys: string | { [K in keyof T]: string }): Promise { - const iterator = (Array.isArray(keys) ? keys : [keys]) as readonly string[] - const promises = iterator.map((key) => once(key, 'refetching')) - const events = await Promise.all(promises) - const details = events.map((event) => event.detail as Promise) + if (typeof keys === 'string') { + const event = await once(keys, 'refetching') + return (await (event.detail as Promise)) as T + } - return (await (Array.isArray(keys) ? Promise.all(details) : details[0])) as T + if (Array.isArray(keys)) { + const promises = keys.map((key) => once(key, 'refetching')) + const events = await Promise.all(promises) + const details = events.map((event) => event.detail as Promise) + return (await Promise.all(details)) as T + } + + const objectKeys = keys as Record + const entries = Object.entries(objectKeys) + const promises = entries.map(([, key]) => once(key, 'refetching')) + const events = await Promise.all(promises) + const details = await Promise.all(events.map((event) => event.detail as Promise)) + const result = Object.fromEntries(entries.map(([name], i) => [name, details[i]])) + return result as T } /** diff --git a/src/react/hooks/useQueryActions.test.tsx b/src/react/hooks/useQueryActions.test.tsx new file mode 100644 index 0000000..bcdcef3 --- /dev/null +++ b/src/react/hooks/useQueryActions.test.tsx @@ -0,0 +1,119 @@ +import { describe, it } from 'vitest' +import { useQueryActions } from 'query/react:hooks/useQueryActions' +import { createQuery } from 'query:index' +import { act, Suspense } from 'react' +import { createRoot } from 'react-dom/client' + +describe('useQueryActions', function () { + it('can refetch data', async ({ expect }) => { + let fetchCount = 0 + + function fetcher() { + fetchCount++ + return Promise.resolve('value-' + fetchCount) + } + + const query = createQuery({ fetcher, expiration: () => 10000 }) + const options = { query } + const actionsRef: { current: ReturnType> | null } = { + current: null, + } + + function Component() { + const actions = useQueryActions('/refetch-key', options) + actionsRef.current = actions + return null + } + + const container = document.createElement('div') + + // oxlint-disable-next-line + await act(async function () { + createRoot(container).render( + + + + ) + }) + + expect(actionsRef.current).not.toBeNull() + expect(fetchCount).toBe(0) + + const result = await actionsRef.current!.refetch() + + expect(result).toBe('value-1') + expect(fetchCount).toBe(1) + }) + + it('can mutate data with correct types', async ({ expect }) => { + function fetcher() { + return Promise.resolve('initial') + } + + const query = createQuery({ fetcher, expiration: () => 10000 }) + const options = { query } + const actionsRef: { current: ReturnType> | null } = { + current: null, + } + + function Component() { + const actions = useQueryActions('/mutate-type-key', options) + actionsRef.current = actions + return null + } + + const container = document.createElement('div') + + // oxlint-disable-next-line + await act(async function () { + createRoot(container).render( + + + + ) + }) + + const result = await actionsRef.current!.mutate('new-value') + + expect(result).toBe('new-value') + }) + + it('can forget cached data', async ({ expect }) => { + function fetcher() { + return Promise.resolve('data') + } + + const query = createQuery({ fetcher, expiration: () => 10000 }) + const options = { query } + const actionsRef: { current: ReturnType> | null } = { + current: null, + } + + // Pre-populate the cache so forget has something to remove. + await query.query('/forget-action-key') + + function Component() { + const actions = useQueryActions('/forget-action-key', options) + actionsRef.current = actions + return null + } + + const container = document.createElement('div') + + // oxlint-disable-next-line + await act(async function () { + createRoot(container).render( + + + + ) + }) + + expect(actionsRef.current).not.toBeNull() + expect(query.keys('items')).toContain('/forget-action-key') + + await actionsRef.current!.forget() + + expect(query.keys('items')).not.toContain('/forget-action-key') + }) +}) diff --git a/src/react/hooks/useQueryActions.ts b/src/react/hooks/useQueryActions.ts index 17a5c30..98758f5 100644 --- a/src/react/hooks/useQueryActions.ts +++ b/src/react/hooks/useQueryActions.ts @@ -90,7 +90,7 @@ export function useQueryActions( }) } - function localMutate(value: MutationValue, options?: MutateOptions) { + function localMutate(value: MutationValue, options?: MutateOptions) { return mutate(key, value, options) } diff --git a/src/react/hooks/useQueryBasic.test.tsx b/src/react/hooks/useQueryBasic.test.tsx new file mode 100644 index 0000000..9ad3150 --- /dev/null +++ b/src/react/hooks/useQueryBasic.test.tsx @@ -0,0 +1,131 @@ +import { describe, it } from 'vitest' +import { useQueryBasic } from 'query/react:hooks/useQueryBasic' +import { createQuery } from 'query:index' +import { act, Suspense, useState } from 'react' +import { createRoot } from 'react-dom/client' + +describe('useQueryBasic', function () { + it('can fetch and display data', async ({ expect }) => { + function fetcher() { + return Promise.resolve('hello') + } + + const query = createQuery({ fetcher }) + const options = { query } + + function Component() { + const { data } = useQueryBasic('/basic-fetch', options) + + return

{data}

+ } + + const container = document.createElement('div') + const promise = query.next('/basic-fetch') + + // oxlint-disable-next-line + await act(async function () { + createRoot(container).render( + + + + ) + }) + + await act(async function () { + await promise + }) + + expect(container.innerText).toBe('hello') + }) + + it('updates data when key changes to a cached value', async ({ expect }) => { + function fetcher(key: string) { + return Promise.resolve(key) + } + + const query = createQuery({ fetcher, expiration: () => 10000 }) + const options = { query } + + let setKey: (key: string) => void = () => {} + + function Component() { + const [key, localSetKey] = useState('/switch-a') + setKey = localSetKey + + const { data } = useQueryBasic(key, options) + + return

{data}

+ } + + const container = document.createElement('div') + const promise = query.next('/switch-a') + + // oxlint-disable-next-line + await act(async function () { + createRoot(container).render( + + + + ) + }) + + await act(async function () { + await promise + }) + + expect(container.innerText).toBe('/switch-a') + + // Pre-populate the second key so the switch resolves instantly. + query.hydrate('/switch-b', '/switch-b', { expiration: () => 10000 }) + + // oxlint-disable-next-line + await act(async function () { + setKey('/switch-b') + }) + + expect(container.innerText).toBe('/switch-b') + }) + + it('updates data when hydrate event fires', async ({ expect }) => { + function fetcher() { + return Promise.resolve('initial') + } + + const query = createQuery({ fetcher, expiration: () => 10000 }) + const options = { query } + + function Component() { + const { data } = useQueryBasic('/hydrate-basic', options) + + return

{data}

+ } + + const container = document.createElement('div') + const promise = query.next('/hydrate-basic') + + // oxlint-disable-next-line + await act(async function () { + createRoot(container).render( + + + + ) + }) + + await act(async function () { + await promise + }) + + expect(container.innerText).toBe('initial') + + // oxlint-disable-next-line + await act(async function () { + query.hydrate('/hydrate-basic', 'updated', { expiration: () => 10000 }) + }) + + // Allow transition to settle. + await act(async function () {}) + + expect(container.innerText).toBe('updated') + }) +}) diff --git a/src/react/hooks/useQueryBasic.ts b/src/react/hooks/useQueryBasic.ts index 59136e4..04dffca 100644 --- a/src/react/hooks/useQueryBasic.ts +++ b/src/react/hooks/useQueryBasic.ts @@ -92,7 +92,16 @@ export function useQueryBasic( fresh: oFresh, }) - const [data, setData] = useState(use(promise)) + const resolved = use(promise) + const [data, setData] = useState(resolved) + + // Sync state when the resolved value changes (e.g. on key change). + // useState only captures the initial value, so when the key changes + // and the new data is already cached, use() returns new data but + // useState ignores it. This comparison detects that drift and resets. + if (data !== resolved) { + setData(resolved) + } const onResolved = useEffectEvent(function (event: CustomEventInit) { startTransition(function () { diff --git a/src/react/hooks/useQueryStatus.test.tsx b/src/react/hooks/useQueryStatus.test.tsx new file mode 100644 index 0000000..478b625 --- /dev/null +++ b/src/react/hooks/useQueryStatus.test.tsx @@ -0,0 +1,74 @@ +import { describe, it } from 'vitest' +import { useQueryStatus } from 'query/react:hooks/useQueryStatus' +import { createQuery } from 'query:index' +import { act, Suspense } from 'react' +import { createRoot } from 'react-dom/client' + +describe.concurrent('useQueryStatus', function () { + it('can read expiration status', async ({ expect }) => { + function fetcher() { + return Promise.resolve('data') + } + + const query = createQuery({ fetcher, expiration: () => 10000 }) + const options = { query } + + await query.query('/key') + + let status: ReturnType | undefined + + function Component() { + status = useQueryStatus('/key', options) + return null + } + + const container = document.createElement('div') + + // oxlint-disable-next-line + await act(async function () { + createRoot(container).render( + + + + ) + }) + + expect(status).toBeDefined() + expect(status!.expiresAt).toBeInstanceOf(Date) + expect(status!.isExpired).toBe(false) + expect(status!.isRefetching).toBe(false) + expect(status!.isMutating).toBe(false) + }) + + it('reports expired status for expired items', async ({ expect }) => { + function fetcher() { + return Promise.resolve('data') + } + + const query = createQuery({ fetcher, expiration: () => 0 }) + const options = { query } + + await query.query('/expired-key') + + let status: ReturnType | undefined + + function Component() { + status = useQueryStatus('/expired-key', options) + return null + } + + const container = document.createElement('div') + + // oxlint-disable-next-line + await act(async function () { + createRoot(container).render( + + + + ) + }) + + expect(status).toBeDefined() + expect(status!.isExpired).toBe(true) + }) +})