Skip to content

Commit b25c0c5

Browse files
authored
Merge pull request #3 from StudioLambda/fix/audit-high-severity-issues
fix: resolve high-severity audit issues in core query and React bindings
2 parents 9646848 + 4cfb119 commit b25c0c5

File tree

10 files changed

+456
-12
lines changed

10 files changed

+456
-12
lines changed

AGENTS.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,25 @@ describe.concurrent('feature', function () {
169169
- `react-in-jsx-scope` rule disabled (using new JSX transform)
170170
- Use `// oxlint-disable-next-line` to disable rules inline
171171

172+
## React Compiler
173+
174+
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.
175+
176+
### Build Configuration
177+
178+
- Runs via `@rolldown/plugin-babel` with `reactCompilerPreset()` exported from `@vitejs/plugin-react`
179+
- Applied only to `src/react/**/*.tsx` files
180+
- 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)
181+
- Peer dependencies: `@babel/core`, `@rolldown/plugin-babel`, `babel-plugin-react-compiler`
182+
183+
### Rules for React Code
184+
185+
- **Do NOT** use `useMemo`, `useCallback`, or `React.memo` — the compiler handles memoization automatically
186+
- **Do NOT** mutate props, state, or values returned from hooks — the compiler assumes immutability
187+
- All React code must strictly follow the [Rules of React](https://react.dev/reference/rules)
188+
- `useEffectEvent` is used for stable event handler references that should not be listed as effect dependencies
189+
- Prefer `function` declarations for components (not arrow functions assigned to variables)
190+
172191
## Commit Conventions
173192

174193
This project uses [Conventional Commits](https://www.conventionalcommits.org/) and git-cliff for automated changelog generation and version bumping.

package-lock.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"prepack": "npm run build"
6565
},
6666
"devDependencies": {
67+
"@babel/core": "^7.29.0",
6768
"@rolldown/plugin-babel": "^0.2.2",
6869
"@types/node": "^25.5.0",
6970
"@types/react": "19.2.14",

src/query/query.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,4 +808,74 @@ describe.concurrent('query', function () {
808808
expect(items).toBe(items)
809809
expect(resolvers).toBe(resolvers)
810810
})
811+
812+
it('respects fresh option from configure()', async ({ expect }) => {
813+
let times = 0
814+
815+
function fetcher() {
816+
times++
817+
return Promise.resolve('value')
818+
}
819+
820+
const { query, configure } = createQuery({ fetcher, expiration: () => 10000 })
821+
822+
await query('key')
823+
expect(times).toBe(1)
824+
825+
configure({ fresh: true })
826+
827+
await query('key')
828+
expect(times).toBe(2)
829+
})
830+
831+
it('fresh option aborts in-flight request and starts new fetch', async ({ expect }) => {
832+
let fetchCount = 0
833+
834+
function fetcher() {
835+
fetchCount++
836+
return Promise.resolve('value-' + fetchCount)
837+
}
838+
839+
const { query } = createQuery({ fetcher, expiration: () => 10000 })
840+
841+
await query('key')
842+
expect(fetchCount).toBe(1)
843+
844+
const result = await query('key', { fresh: true })
845+
expect(fetchCount).toBe(2)
846+
expect(result).toBe('value-2')
847+
})
848+
849+
it('can use next() with object keys', async ({ expect }) => {
850+
function fetcher(key: string) {
851+
return Promise.resolve(key)
852+
}
853+
854+
const { query, next } = createQuery({ fetcher })
855+
856+
const promise = next<{ a: string; b: string }>({ a: '/foo', b: '/bar' })
857+
858+
await Promise.all([query('/foo'), query('/bar')])
859+
860+
const result = await promise
861+
862+
expect(result.a).toBe('/foo')
863+
expect(result.b).toBe('/bar')
864+
})
865+
866+
it('can use next() with a single string key', async ({ expect }) => {
867+
function fetcher(key: string) {
868+
return Promise.resolve(key)
869+
}
870+
871+
const { query, next } = createQuery({ fetcher })
872+
873+
const promise = next<string>('/foo')
874+
875+
await query('/foo')
876+
877+
const result = await promise
878+
879+
expect(result).toBe('/foo')
880+
})
811881
})

src/query/query.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ export function createQuery(instanceOptions?: Configuration): Query {
342342
* Determines if the result should be a fresh fetched
343343
* instance regardless of any cached value or its expiration time.
344344
*/
345-
const fresh = options?.fresh ?? instanceOptions?.fresh
345+
const fresh = options?.fresh ?? instanceFresh
346346

347347
// Force fetching of the data.
348348
function refetch(key: string): Promise<T> {
@@ -415,8 +415,11 @@ export function createQuery(instanceOptions?: Configuration): Query {
415415
}
416416

417417
// We want to force a fresh item ignoring any current cached
418-
// value or its expiration time.
418+
// value or its expiration time. Abort any existing in-flight
419+
// request so that refetch starts a genuinely new fetch instead
420+
// of returning the pending deduplication promise.
419421
if (fresh) {
422+
abort(key)
420423
return refetch(key)
421424
}
422425

@@ -498,16 +501,33 @@ export function createQuery(instanceOptions?: Configuration): Query {
498501
* Waits for the next refetching event on one or more keys and returns
499502
* the resolved values. Useful for synchronizing with query updates.
500503
*
501-
* @param keys - A single key or an object mapping property names to keys.
504+
* Supports a single key (returns a single value), an array of keys
505+
* (returns an array of values), or an object mapping property names
506+
* to keys (returns an object with the same shape).
507+
*
508+
* @param keys - A single key, array of keys, or object mapping names to keys.
502509
* @returns A promise that resolves with the fetched value(s).
503510
*/
504511
async function next<T = unknown>(keys: string | { [K in keyof T]: string }): Promise<T> {
505-
const iterator = (Array.isArray(keys) ? keys : [keys]) as readonly string[]
506-
const promises = iterator.map((key) => once(key, 'refetching'))
507-
const events = await Promise.all(promises)
508-
const details = events.map((event) => event.detail as Promise<T>)
512+
if (typeof keys === 'string') {
513+
const event = await once(keys, 'refetching')
514+
return (await (event.detail as Promise<T>)) as T
515+
}
509516

510-
return (await (Array.isArray(keys) ? Promise.all(details) : details[0])) as T
517+
if (Array.isArray(keys)) {
518+
const promises = keys.map((key) => once(key, 'refetching'))
519+
const events = await Promise.all(promises)
520+
const details = events.map((event) => event.detail as Promise<T>)
521+
return (await Promise.all(details)) as T
522+
}
523+
524+
const objectKeys = keys as Record<string, string>
525+
const entries = Object.entries(objectKeys)
526+
const promises = entries.map(([, key]) => once(key, 'refetching'))
527+
const events = await Promise.all(promises)
528+
const details = await Promise.all(events.map((event) => event.detail as Promise<unknown>))
529+
const result = Object.fromEntries(entries.map(([name], i) => [name, details[i]]))
530+
return result as T
511531
}
512532

513533
/**
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, it } from 'vitest'
2+
import { useQueryActions } from 'query/react:hooks/useQueryActions'
3+
import { createQuery } from 'query:index'
4+
import { act, Suspense } from 'react'
5+
import { createRoot } from 'react-dom/client'
6+
7+
describe('useQueryActions', function () {
8+
it('can refetch data', async ({ expect }) => {
9+
let fetchCount = 0
10+
11+
function fetcher() {
12+
fetchCount++
13+
return Promise.resolve('value-' + fetchCount)
14+
}
15+
16+
const query = createQuery({ fetcher, expiration: () => 10000 })
17+
const options = { query }
18+
const actionsRef: { current: ReturnType<typeof useQueryActions<string>> | null } = {
19+
current: null,
20+
}
21+
22+
function Component() {
23+
const actions = useQueryActions<string>('/refetch-key', options)
24+
actionsRef.current = actions
25+
return null
26+
}
27+
28+
const container = document.createElement('div')
29+
30+
// oxlint-disable-next-line
31+
await act(async function () {
32+
createRoot(container).render(
33+
<Suspense fallback="loading">
34+
<Component />
35+
</Suspense>
36+
)
37+
})
38+
39+
expect(actionsRef.current).not.toBeNull()
40+
expect(fetchCount).toBe(0)
41+
42+
const result = await actionsRef.current!.refetch()
43+
44+
expect(result).toBe('value-1')
45+
expect(fetchCount).toBe(1)
46+
})
47+
48+
it('can mutate data with correct types', async ({ expect }) => {
49+
function fetcher() {
50+
return Promise.resolve('initial')
51+
}
52+
53+
const query = createQuery({ fetcher, expiration: () => 10000 })
54+
const options = { query }
55+
const actionsRef: { current: ReturnType<typeof useQueryActions<string>> | null } = {
56+
current: null,
57+
}
58+
59+
function Component() {
60+
const actions = useQueryActions<string>('/mutate-type-key', options)
61+
actionsRef.current = actions
62+
return null
63+
}
64+
65+
const container = document.createElement('div')
66+
67+
// oxlint-disable-next-line
68+
await act(async function () {
69+
createRoot(container).render(
70+
<Suspense fallback="loading">
71+
<Component />
72+
</Suspense>
73+
)
74+
})
75+
76+
const result = await actionsRef.current!.mutate('new-value')
77+
78+
expect(result).toBe('new-value')
79+
})
80+
81+
it('can forget cached data', async ({ expect }) => {
82+
function fetcher() {
83+
return Promise.resolve('data')
84+
}
85+
86+
const query = createQuery({ fetcher, expiration: () => 10000 })
87+
const options = { query }
88+
const actionsRef: { current: ReturnType<typeof useQueryActions<string>> | null } = {
89+
current: null,
90+
}
91+
92+
// Pre-populate the cache so forget has something to remove.
93+
await query.query('/forget-action-key')
94+
95+
function Component() {
96+
const actions = useQueryActions<string>('/forget-action-key', options)
97+
actionsRef.current = actions
98+
return null
99+
}
100+
101+
const container = document.createElement('div')
102+
103+
// oxlint-disable-next-line
104+
await act(async function () {
105+
createRoot(container).render(
106+
<Suspense fallback="loading">
107+
<Component />
108+
</Suspense>
109+
)
110+
})
111+
112+
expect(actionsRef.current).not.toBeNull()
113+
expect(query.keys('items')).toContain('/forget-action-key')
114+
115+
await actionsRef.current!.forget()
116+
117+
expect(query.keys('items')).not.toContain('/forget-action-key')
118+
})
119+
})

src/react/hooks/useQueryActions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export function useQueryActions<T = unknown>(
9090
})
9191
}
9292

93-
function localMutate<T = unknown>(value: MutationValue<T>, options?: MutateOptions<T>) {
93+
function localMutate(value: MutationValue<T>, options?: MutateOptions<T>) {
9494
return mutate(key, value, options)
9595
}
9696

0 commit comments

Comments
 (0)