Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
70 changes: 70 additions & 0 deletions src/query/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('/foo')

await query('/foo')

const result = await promise

expect(result).toBe('/foo')
})
})
36 changes: 28 additions & 8 deletions src/query/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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<T = unknown>(keys: string | { [K in keyof T]: string }): Promise<T> {
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<T>)
if (typeof keys === 'string') {
const event = await once(keys, 'refetching')
return (await (event.detail as Promise<T>)) 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<T>)
return (await Promise.all(details)) as T
}

const objectKeys = keys as Record<string, string>
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<unknown>))
const result = Object.fromEntries(entries.map(([name], i) => [name, details[i]]))
return result as T
}

/**
Expand Down
119 changes: 119 additions & 0 deletions src/react/hooks/useQueryActions.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useQueryActions<string>> | null } = {
current: null,
}

function Component() {
const actions = useQueryActions<string>('/refetch-key', options)
actionsRef.current = actions
return null
}

const container = document.createElement('div')

// oxlint-disable-next-line
await act(async function () {
createRoot(container).render(
<Suspense fallback="loading">
<Component />
</Suspense>
)
})

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<typeof useQueryActions<string>> | null } = {
current: null,
}

function Component() {
const actions = useQueryActions<string>('/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(
<Suspense fallback="loading">
<Component />
</Suspense>
)
})

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<typeof useQueryActions<string>> | null } = {
current: null,
}

// Pre-populate the cache so forget has something to remove.
await query.query('/forget-action-key')

function Component() {
const actions = useQueryActions<string>('/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(
<Suspense fallback="loading">
<Component />
</Suspense>
)
})

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')
})
})
2 changes: 1 addition & 1 deletion src/react/hooks/useQueryActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function useQueryActions<T = unknown>(
})
}

function localMutate<T = unknown>(value: MutationValue<T>, options?: MutateOptions<T>) {
function localMutate(value: MutationValue<T>, options?: MutateOptions<T>) {
return mutate(key, value, options)
}

Expand Down
Loading
Loading