feat: Infinite query hook block (#34650)

* infinite list

* infinite list block

* registration

* add missing supportedFrameworks

* Add tables to the supabase project. Generate the types for it.

* Refactor the infinite list query to be just a hook.

* Clean up the block. Add comments.

* Regenerate the registry.

* Fix the docs, the block is not framework-dependent.

* Set the package versions to * to be defined by other packages.

* Minor fixes to the block.

* Fix the examples.

* Fix the docs for the new hook.

* Fix the demo.

* Add more migrations to the db.

* Fix various issues with the query. Rewrote it to useSyncExternalStore.

* Fix the SSR for the hook.

* More fixes.

* Try initializing the store in a useEffect.

* Fix the pnpm-lock file.

* Minor fixes in the docs.

* Put the infinite list under a reusable components section.

* Update apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts

* Change the example DB to use todos.

* Update the docs to be about Todos quickstart.

* List edits

* Fix link

* Regenerate the registry.

* Add query hook to the landing page.

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
Co-authored-by: Terry Sutton <saltcod@gmail.com>
This commit is contained in:
Saxon Fletcher
2025-04-17 23:18:33 +10:00
committed by GitHub
parent 2ebf81c601
commit 95ec79b98f
23 changed files with 1381 additions and 18 deletions

View File

@@ -34,3 +34,11 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
## Supabase types
To regenerate the Supabase database types, run
```
supabase gen types --local > registry/default/fixtures/database.types.ts
```

View File

@@ -343,6 +343,18 @@ export const Index: Record<string, any> = {
chunks: []
}
,
"infinite-query-hook": {
name: "infinite-query-hook",
type: "registry:block",
registryDependencies: [],
source: "",
files: ["registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts"],
category: "undefined",
subcategory: "undefined",
chunks: []
}
,
"supabase-client-nextjs": {
name: "supabase-client-nextjs",
type: "registry:lib",
@@ -486,6 +498,18 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
}
,
"infinite-query-hook-demo": {
name: "infinite-query-hook-demo",
type: "registry:example",
registryDependencies: [],
component: React.lazy(() => import("@/registry/default/examples/infinite-query-hook-demo.tsx")),
source: "",
files: ["registry/default/examples/infinite-query-hook-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
}
},
}

View File

@@ -178,6 +178,24 @@ export default function Home() {
</div>
</div>
<HorizontalGridLine />
{/* Infinite Query Hook */}
<div className="col-start-2 col-span-10 md:col-start-3 md:col-span-8 pt-16 pb-6 text-xs uppercase font-mono text-foreground-light tracking-wider relative flex justify-between items-center">
<span>Infinite Query Hook</span>
<Link
className="text-foreground underline decoration-1 decoration-foreground-muted underline-offset-4 transition-colors hover:decoration-brand hover:decoration-2"
href="/docs/nextjs/social-auth"
>
Go to block
</Link>
</div>
<HorizontalGridLine />
<div className="col-start-2 col-span-10 md:col-start-3 md:col-span-8 relative">
<div className="-mt-4">
<BlockPreview name="infinite-list-demo" />
</div>
</div>
<HorizontalGridLine />
</div>
</div>
</div>

View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next'
import { BaseInjector } from './../base-injector'
export const metadata: Metadata = {
title: 'Infinite Data Table Demo',
description: 'Demonstration of the Infinite Data Table component.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<>
<BaseInjector />
<div>
<div>{children}</div>
</div>
</>
)
}

View File

@@ -0,0 +1,5 @@
import InfiniteQueryHookDemo from '@/registry/default/examples/infinite-query-hook-demo'
export default function Page() {
return <InfiniteQueryHookDemo />
}

View File

@@ -97,6 +97,14 @@ export const componentPages: SidebarNavGroup = {
items: [],
commandItemLabel: 'Realtime Chat',
},
{
title: 'Infinite Query Hook',
supportedFrameworks: [],
href: '/docs/infinite-query-hook',
new: true,
items: [],
commandItemLabel: 'Infinite Query Hook',
},
],
}

View File

@@ -0,0 +1,290 @@
---
title: Infinite Query Hook
description: React hook for infinite lists, fetching data from Supabase.
---
<BlockPreview name="infinite-list-demo" />
## Installation
<BlockItem
name="infinite-query-hook"
description="Installs the Infinite List component and necessary Supabase client setup."
/>
## Folder structure
<RegistryBlock itemName="infinite-query-hook" />
## Introduction
The Infinite Query Hook provides a single React hook which will make it easier to load data progressively from your Supabase database. It handles data fetching and pagination state, It is meant to be used with infinite lists or tables.
The hook is fully typed, provided you have generated and setup your database types.
## Adding types
Before using this hook, we **highly** recommend you setup database types in your project. This will make the hook fully-typesafe. More info about generating Typescript types from database schema [here](https://supabase.com/docs/guides/api/rest/generating-types)
## Props
| Prop | Type | Description |
| --------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
| `tableName` | `string` | **Required.** The name of the Supabase table to fetch data from. |
| `columns` | `string` | Columns to select from the table. Defaults to `'*'`. |
| `pageSize` | `number` | Number of items to fetch per page. Defaults to `20`. |
| `trailingQuery` | `(query: SupabaseSelectBuilder) => SupabaseSelectBuilder` | Function to apply filters or sorting to the Supabase query. |
## Return type
data, count, isSuccess, isLoading, isFetching, error, hasMore, fetchNextPage
| Prop | Type | Description |
| --------------- | ------------- | ----------------------------------------------------------------------------------- |
| `data` | `TableData[]` | An array of fetched items. |
| `count` | `number` | Number of total items in the database. It takes `trailingQuery` into consideration. |
| `isSuccess` | `boolean` | It's true if the last API call succeeded. |
| `isLoading` | `boolean` | It's true only for the initial fetch. |
| `isFetching` | `boolean` | It's true for the initial and all incremental fetches. |
| `error` | `any` | The error from the last fetch. |
| `hasMore` | `boolean` | Whether the query has finished fetching all items from the database |
| `fetchNextPage` | `() => void` | Sends a new request for the next items |
## Type safety
The hook will use the typed defined on your Supabase client if they're setup ([more info](https://supabase.com/docs/reference/javascript/typescript-support)).
The hook also supports an custom defined result type by using `useInfiniteQuery<T>`. For example, if you have a custom type for `Product`, you can use it like this `useInfiniteQuery<Product>`.
## Usage
### With sorting
```tsx
const { data, fetchNextPage } = useInfiniteQuery({
tableName: 'products',
columns: '*',
pageSize: 10,
trailingQuery: (query) => query.order('created_at', { ascending: false }),
})
return (
<div>
{data.map((item) => (
<ProductCard key={item.id} product={item} />
))}
<Button onClick={fetchNextPage}>Load more products</Button>
</div>
)
```
### With filtering on search params
This example will filter based on a search param like `example.com/?q=hello`.
```tsx
const params = useSearchParams()
const searchQuery = params.get('q')
const { data, isLoading, isFetching, fetchNextPage, count, isSuccess } = useInfiniteQuery({
tableName: 'products',
columns: '*',
pageSize: 10,
trailingQuery: (query) => {
if (searchQuery && searchQuery.length > 0) {
query = query.ilike('name', `%${searchQuery}%`)
}
return query
},
})
return (
<div>
{data.map((item) => (
<ProductCard key={item.id} product={item} />
))}
<Button onClick={fetchNextPage}>Load more products</Button>
</div>
)
```
## Reusable components
### Infinite list (fetches as you scroll)
The following component abstracts the hook into a component. It includes few utility components for no results and end of the list.
```tsx
'use client'
import { cn } from '@/lib/utils'
import {
SupabaseQueryHandler,
SupabaseTableData,
SupabaseTableName,
useInfiniteQuery,
} from '@/hooks/use-infinite-query'
import * as React from 'react'
interface InfiniteListProps<TableName extends SupabaseTableName> {
tableName: TableName
columns?: string
pageSize?: number
trailingQuery?: SupabaseQueryHandler<TableName>
renderItem: (item: SupabaseTableData<TableName>, index: number) => React.ReactNode
className?: string
renderNoResults?: () => React.ReactNode
renderEndMessage?: () => React.ReactNode
renderSkeleton?: (count: number) => React.ReactNode
}
const DefaultNoResults = () => (
<div className="text-center text-muted-foreground py-10">No results.</div>
)
const DefaultEndMessage = () => (
<div className="text-center text-muted-foreground py-4 text-sm">You&apos;ve reached the end.</div>
)
const defaultSkeleton = (count: number) => (
<div className="flex flex-col gap-2 px-4">
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="h-4 w-full bg-muted animate-pulse" />
))}
</div>
)
export function InfiniteList<TableName extends SupabaseTableName>({
tableName,
columns = '*',
pageSize = 20,
trailingQuery,
renderItem,
className,
renderNoResults = DefaultNoResults,
renderEndMessage = DefaultEndMessage,
renderSkeleton = defaultSkeleton,
}: InfiniteListProps<TableName>) {
const { data, isFetching, hasMore, fetchNextPage, isSuccess } = useInfiniteQuery({
tableName,
columns,
pageSize,
trailingQuery,
})
// Ref for the scrolling container
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
// Intersection observer logic - target the last rendered *item* or a dedicated sentinel
const loadMoreSentinelRef = React.useRef<HTMLDivElement>(null)
const observer = React.useRef<IntersectionObserver | null>(null)
React.useEffect(() => {
if (observer.current) observer.current.disconnect()
observer.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isFetching) {
fetchNextPage()
}
},
{
root: scrollContainerRef.current, // Use the scroll container for scroll detection
threshold: 0.1, // Trigger when 10% of the target is visible
rootMargin: '0px 0px 100px 0px', // Trigger loading a bit before reaching the end
}
)
if (loadMoreSentinelRef.current) {
observer.current.observe(loadMoreSentinelRef.current)
}
return () => {
if (observer.current) observer.current.disconnect()
}
}, [isFetching, hasMore, fetchNextPage])
return (
<div ref={scrollContainerRef} className={cn('relative h-full overflow-auto', className)}>
<div>
{isSuccess && data.length === 0 && renderNoResults()}
{data.map((item, index) => renderItem(item, index))}
{isFetching && renderSkeleton && renderSkeleton(pageSize)}
<div ref={loadMoreSentinelRef} style={{ height: '1px' }} />
{!hasMore && data.length > 0 && renderEndMessage()}
</div>
</div>
)
}
```
Use the `InfiniteList` component with the [Todo List](https://supabase.com/dashboard/project/_/sql/quickstarts) quickstart.
Add `<InfiniteListDemo />` to a page to see it in action.
Ensure the [Checkbox](https://ui.shadcn.com/docs/components/checkbox) component from shadcn/ui is installed, and [regenerate/download](https://supabase.com/docs/guides/api/rest/generating-types) types after running the quickstart.
```tsx
'use client'
import { Checkbox } from '@/components/ui/checkbox'
import { InfiniteList } from './infinite-component'
import { SupabaseQueryHandler } from '@/hooks/use-infinite-query'
import { Database } from '@/lib/supabase.types'
type TodoTask = Database['public']['Tables']['todos']['Row']
// Define how each item should be rendered
const renderTodoItem = (todo: TodoTask) => {
return (
<div
key={todo.id}
className="border-b py-3 px-4 hover:bg-muted flex items-center justify-between"
>
<div className="flex items-center gap-3">
<Checkbox defaultChecked={todo.is_complete ?? false} />
<div>
<span className="font-medium text-sm text-foreground">{todo.task}</span>
<div className="text-sm text-muted-foreground">
{new Date(todo.inserted_at).toLocaleDateString()}
</div>
</div>
</div>
</div>
)
}
const orderByInsertedAt: SupabaseQueryHandler<'todos'> = (query) => {
return query.order('inserted_at', { ascending: false })
}
export const InfiniteListDemo = () => {
return (
<div className="bg-background h-[600px]">
<InfiniteList
tableName="todos"
renderItem={renderTodoItem}
pageSize={3}
trailingQuery={orderByInsertedAt}
/>
</div>
)
}
```
<Callout>
The Todo List table has Row Level Security (RLS) enabled by default. Feel free disable it
temporarily while testing. With RLS enabled, you will get an [empty
array](https://supabase.com/docs/guides/troubleshooting/why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx)
of results by default. [Read
more](https://supabase.com/docs/guides/database/postgres/row-level-security) about RLS.
</Callout>
## Further reading
- [Generating Typescript types from the database](https://supabase.com/docs/reference/javascript/typescript-support)
- [Supabase Database API](https://supabase.com/docs/reference/javascript/select)
- [Supabase Pagination](https://supabase.com/docs/reference/javascript/select#pagination)
- [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)

View File

@@ -46,6 +46,7 @@ export function generateRegistryTree(registryPath: string): RegistryNode[] {
// Remove any paths in the file content that point to the block directory.
const content = file.content
.replaceAll(/@\/registry\/default\/blocks\/.+?\//gi, '@/')
.replaceAll(/@\/registry\/default\/fixtures\//gi, '@/')
.replaceAll(/@\/registry\/default\//gi, '@/')
.replaceAll(/@\/clients\/.+?\//gi, '@/')

View File

@@ -17,14 +17,16 @@
"typecheck": "contentlayer2 build && tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.0.7",
"@radix-ui/react-avatar": "*",
"@radix-ui/react-checkbox": "*",
"@radix-ui/react-label": "*",
"@radix-ui/react-progress": "*",
"@radix-ui/react-slot": "*",
"@radix-ui/react-tooltip": "*",
"@react-router/fs-routes": "^7.4.0",
"@supabase/postgrest-js": "*",
"@supabase/supa-mdx-lint": "0.2.6-alpha",
"class-variance-authority": "^0.6.0",
"class-variance-authority": "*",
"common": "workspace:*",
"contentlayer2": "0.4.6",
"eslint-config-supabase": "workspace:*",

File diff suppressed because one or more lines are too long

View File

@@ -1115,6 +1115,21 @@
}
]
},
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "infinite-query-hook",
"type": "registry:block",
"title": "Infinite Query Hook",
"description": "React hook for infinite lists, fetching data from Supabase.",
"dependencies": ["@supabase/supabase-js", "@supabase/postgrest-js@*"],
"registryDependencies": [],
"files": [
{
"path": "registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts",
"type": "registry:hook"
}
]
},
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "supabase-client-nextjs",

View File

@@ -1,7 +1,14 @@
import { type Registry, type RegistryItem } from 'shadcn/registry'
import { clients } from './clients'
import { registryItemAppend } from './utils'
import currentUserAvatar from './default/blocks/current-user-avatar/registry-item.json' with { type: 'json' }
import dropzone from './default/blocks/dropzone/registry-item.json' with { type: 'json' }
import infiniteQueryHook from './default/blocks/infinite-query-hook/registry-item.json' with { type: 'json' }
import realtimeAvatarStack from './default/blocks/realtime-avatar-stack/registry-item.json' with { type: 'json' }
import realtimeChat from './default/blocks/realtime-chat/registry-item.json' with { type: 'json' }
import realtimeCursor from './default/blocks/realtime-cursor/registry-item.json' with { type: 'json' }
import passwordBasedAuthNextjs from './default/blocks/password-based-auth-nextjs/registry-item.json' with { type: 'json' }
import passwordBasedAuthReactRouter from './default/blocks/password-based-auth-react-router/registry-item.json' with { type: 'json' }
import passwordBasedAuthReact from './default/blocks/password-based-auth-react/registry-item.json' with { type: 'json' }
@@ -12,12 +19,6 @@ import socialAuthReactRouter from './default/blocks/social-auth-react-router/reg
import socialAuthReact from './default/blocks/social-auth-react/registry-item.json' with { type: 'json' }
import socialAuthTanstack from './default/blocks/social-auth-tanstack/registry-item.json' with { type: 'json' }
import realtimeAvatarStack from './default/blocks/realtime-avatar-stack/registry-item.json' with { type: 'json' }
import realtimeChat from './default/blocks/realtime-chat/registry-item.json' with { type: 'json' }
import realtimeCursor from './default/blocks/realtime-cursor/registry-item.json' with { type: 'json' }
import { registryItemAppend } from './utils'
const combine = (component: Registry['items'][number]) => {
return clients.flatMap((client) => {
return registryItemAppend(
@@ -51,4 +52,6 @@ export const blocks = [
...combine(currentUserAvatar as RegistryItem),
...combine(realtimeAvatarStack as RegistryItem),
...combine(realtimeChat as RegistryItem),
// infinite query hook is intentionally not combined with the clients since it depends on clients having database types.
infiniteQueryHook as RegistryItem,
] as Registry['items']

View File

@@ -0,0 +1,210 @@
'use client'
import { createClient } from '@/registry/default/fixtures/lib/supabase/client'
import { PostgrestQueryBuilder } from '@supabase/postgrest-js'
import { SupabaseClient } from '@supabase/supabase-js'
import { useEffect, useRef, useSyncExternalStore } from 'react'
const supabase = createClient()
// The following types are used to make the hook type-safe. It extracts the database type from the supabase client.
type SupabaseClientType = typeof supabase
// Utility type to check if the type is any
type IfAny<T, Y, N> = 0 extends 1 & T ? Y : N
// Extracts the database type from the supabase client. If the supabase client doesn't have a type, it will fallback properly.
type Database =
SupabaseClientType extends SupabaseClient<infer U>
? IfAny<
U,
{
public: {
Tables: Record<string, any>
Views: Record<string, any>
Functions: Record<string, any>
}
},
U
>
: never
// Change this to the database schema you want to use
type DatabaseSchema = Database['public']
// Extracts the table names from the database type
type SupabaseTableName = keyof DatabaseSchema['Tables']
// Extracts the table definition from the database type
type SupabaseTableData<T extends SupabaseTableName> = DatabaseSchema['Tables'][T]['Row']
type SupabaseSelectBuilder<T extends SupabaseTableName> = ReturnType<
PostgrestQueryBuilder<DatabaseSchema, DatabaseSchema['Tables'][T], T>['select']
>
// A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.
type SupabaseQueryHandler<T extends SupabaseTableName> = (
query: SupabaseSelectBuilder<T>
) => SupabaseSelectBuilder<T>
interface UseInfiniteQueryProps<T extends SupabaseTableName, Query extends string = '*'> {
// The table name to query
tableName: T
// The columns to select, defaults to `*`
columns?: string
// The number of items to fetch per page, defaults to `20`
pageSize?: number
// A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.
trailingQuery?: SupabaseQueryHandler<T>
}
interface StoreState<TData> {
data: TData[]
count: number
isSuccess: boolean
isLoading: boolean
isFetching: boolean
error: Error | null
hasInitialFetch: boolean
}
type Listener = () => void
function createStore<TData extends SupabaseTableData<T>, T extends SupabaseTableName>(
props: UseInfiniteQueryProps<T>
) {
const { tableName, columns = '*', pageSize = 20, trailingQuery } = props
let state: StoreState<TData> = {
data: [],
count: 0,
isSuccess: false,
isLoading: false,
isFetching: false,
error: null,
hasInitialFetch: false,
}
const listeners = new Set<Listener>()
const notify = () => {
listeners.forEach((listener) => listener())
}
const setState = (newState: Partial<StoreState<TData>>) => {
state = { ...state, ...newState }
notify()
}
const fetchPage = async (skip: number) => {
if (state.hasInitialFetch && (state.isFetching || state.count <= state.data.length)) return
setState({ isFetching: true })
let query = supabase
.from(tableName)
.select(columns, { count: 'exact' }) as unknown as SupabaseSelectBuilder<T>
if (trailingQuery) {
query = trailingQuery(query)
}
const { data: newData, count, error } = await query.range(skip, skip + pageSize - 1)
if (error) {
console.error('An unexpected error occurred:', error)
setState({ error })
} else {
const deduplicatedData = ((newData || []) as TData[]).filter(
(item) => !state.data.find((old) => old.id === item.id)
)
setState({
data: [...state.data, ...deduplicatedData],
count: count || 0,
isSuccess: true,
error: null,
})
}
setState({ isFetching: false })
}
const fetchNextPage = async () => {
if (state.isFetching) return
await fetchPage(state.data.length)
}
const initialize = async () => {
setState({ isLoading: true, isSuccess: false, data: [] })
await fetchNextPage()
setState({ isLoading: false, hasInitialFetch: true })
}
return {
getState: () => state,
subscribe: (listener: Listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
fetchNextPage,
initialize,
}
}
// Empty initial state to avoid hydration errors.
const initialState: any = {
data: [],
count: 0,
isSuccess: false,
isLoading: false,
isFetching: false,
error: null,
hasInitialFetch: false,
}
function useInfiniteQuery<
TData extends SupabaseTableData<T>,
T extends SupabaseTableName = SupabaseTableName,
>(props: UseInfiniteQueryProps<T>) {
const storeRef = useRef(createStore<TData, T>(props))
const state = useSyncExternalStore(
storeRef.current.subscribe,
() => storeRef.current.getState(),
() => initialState as StoreState<TData>
)
useEffect(() => {
// Recreate store if props change
if (
storeRef.current.getState().hasInitialFetch &&
(props.tableName !== props.tableName ||
props.columns !== props.columns ||
props.pageSize !== props.pageSize)
) {
storeRef.current = createStore<TData, T>(props)
}
if (!state.hasInitialFetch && typeof window !== 'undefined') {
storeRef.current.initialize()
}
}, [props.tableName, props.columns, props.pageSize, state.hasInitialFetch])
return {
data: state.data,
count: state.count,
isSuccess: state.isSuccess,
isLoading: state.isLoading,
isFetching: state.isFetching,
error: state.error,
hasMore: state.count > state.data.length,
fetchNextPage: storeRef.current.fetchNextPage,
}
}
export {
useInfiniteQuery,
type SupabaseQueryHandler,
type SupabaseTableData,
type SupabaseTableName,
type UseInfiniteQueryProps,
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "infinite-query-hook",
"type": "registry:block",
"title": "Infinite Query Hook",
"description": "React hook for infinite lists, fetching data from Supabase.",
"dependencies": ["@supabase/supabase-js", "@supabase/postgrest-js@*"],
"registryDependencies": [],
"files": [
{
"path": "registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts",
"type": "registry:hook"
}
]
}

View File

@@ -0,0 +1,28 @@
'use client'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-primary/10', className)} {...props} />
}
export { Skeleton }

View File

@@ -0,0 +1,149 @@
'use client'
import { cn } from '@/lib/utils'
import {
SupabaseQueryHandler,
SupabaseTableData,
SupabaseTableName,
useInfiniteQuery,
} from '@/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query'
import { Checkbox } from '@/registry/default/components/ui/checkbox'
import { Database } from '@/registry/default/fixtures/database.types'
import * as React from 'react'
interface InfiniteListProps<TableName extends SupabaseTableName> {
tableName: TableName
columns?: string
pageSize?: number
trailingQuery?: SupabaseQueryHandler<TableName>
renderItem: (item: SupabaseTableData<TableName>, index: number) => React.ReactNode
className?: string
renderNoResults?: () => React.ReactNode
renderEndMessage?: () => React.ReactNode
renderSkeleton?: (count: number) => React.ReactNode
}
const DefaultNoResults = () => (
<div className="text-center text-muted-foreground py-10">No results.</div>
)
const DefaultEndMessage = () => (
<div className="text-center text-muted-foreground py-4 text-sm">You&apos;ve reached the end.</div>
)
const defaultSkeleton = (count: number) => (
<div className="flex flex-col gap-2 px-4">
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="h-4 w-full bg-muted animate-pulse" />
))}
</div>
)
export function InfiniteList<TableName extends SupabaseTableName>({
tableName,
columns = '*',
pageSize = 20,
trailingQuery,
renderItem,
className,
renderNoResults = DefaultNoResults,
renderEndMessage = DefaultEndMessage,
renderSkeleton = defaultSkeleton,
}: InfiniteListProps<TableName>) {
const { data, isFetching, hasMore, fetchNextPage, isSuccess } = useInfiniteQuery({
tableName,
columns,
pageSize,
trailingQuery,
})
// Ref for the scrolling container
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
// Intersection observer logic - target the last rendered *item* or a dedicated sentinel
const loadMoreSentinelRef = React.useRef<HTMLDivElement>(null)
const observer = React.useRef<IntersectionObserver | null>(null)
React.useEffect(() => {
if (observer.current) observer.current.disconnect()
observer.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isFetching) {
fetchNextPage()
}
},
{
root: scrollContainerRef.current, // Use the scroll container for scroll detection
threshold: 0.1, // Trigger when 10% of the target is visible
rootMargin: '0px 0px 100px 0px', // Trigger loading a bit before reaching the end
}
)
if (loadMoreSentinelRef.current) {
observer.current.observe(loadMoreSentinelRef.current)
}
return () => {
if (observer.current) observer.current.disconnect()
}
}, [isFetching, hasMore, fetchNextPage])
return (
<div ref={scrollContainerRef} className={cn('relative h-full overflow-auto', className)}>
<div>
{isSuccess && data.length === 0 && renderNoResults()}
{data.map((item, index) => renderItem(item, index))}
{isFetching && renderSkeleton && renderSkeleton(pageSize)}
<div ref={loadMoreSentinelRef} style={{ height: '1px' }} />
{!hasMore && data.length > 0 && renderEndMessage()}
</div>
</div>
)
}
type TodoTask = Database['public']['Tables']['todos']['Row']
// Define how each item should be rendered
const renderTodoItem = (todo: TodoTask) => {
return (
<div
key={todo.id}
className="border-b py-3 px-4 hover:bg-muted flex items-center justify-between"
>
<div className="flex items-center gap-3">
<Checkbox defaultChecked={todo.is_complete ?? false} />
<div>
<span className="font-medium text-sm text-foreground">{todo.task}</span>
<div className="text-sm text-muted-foreground">
{new Date(todo.inserted_at).toLocaleDateString()}
</div>
</div>
</div>
</div>
)
}
// Define a filter to only show logs with log_level = 'info'
const orderByInsertedAt: SupabaseQueryHandler<'todos'> = (query) => {
return query.order('inserted_at', { ascending: false })
}
const InfiniteListDemo = () => {
return (
<div className="bg-background h-[600px]">
<InfiniteList
tableName="todos"
renderItem={renderTodoItem}
pageSize={3}
trailingQuery={orderByInsertedAt}
/>
</div>
)
}
export default InfiniteListDemo

View File

@@ -0,0 +1,179 @@
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]
export type Database = {
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
operationName?: string
query?: string
variables?: Json
extensions?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
todos: {
Row: {
id: number
inserted_at: string
is_complete: boolean | null
task: string | null
user_id: string
}
Insert: {
id?: number
inserted_at?: string
is_complete?: boolean | null
task?: string | null
user_id: string
}
Update: {
id?: number
inserted_at?: string
is_complete?: boolean | null
task?: string | null
user_id?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DefaultSchema = Database[Extract<keyof Database, 'public'>]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
? (DefaultSchema['Tables'] & DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema['Tables']
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema['Tables']
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] | { schema: keyof Database },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums']
: never = never,
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums']
? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema['CompositeTypes']
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database
}
? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes']
? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {},
},
} as const

View File

@@ -0,0 +1,9 @@
import { createBrowserClient } from '@supabase/ssr'
import { Database } from '../../database.types'
// This client is meant to be used for demo purposes only. It has types from the Supabase project in the ui-library app.
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}

View File

@@ -78,4 +78,15 @@ export const examples: Registry['items'] = [
},
],
},
{
name: 'infinite-query-hook-demo',
type: 'registry:example',
registryDependencies: [],
files: [
{
path: 'registry/default/examples/infinite-query-hook-demo.tsx',
type: 'registry:example',
},
],
},
]

View File

@@ -0,0 +1,99 @@
create table "public"."todos" (
"id" bigint generated by default as identity not null,
"user_id" uuid not null,
"task" text,
"is_complete" boolean default false,
"inserted_at" timestamp with time zone not null default timezone('utc'::text, now())
);
alter table "public"."todos" enable row level security;
CREATE UNIQUE INDEX todos_pkey ON public.todos USING btree (id);
alter table "public"."todos" add constraint "todos_pkey" PRIMARY KEY using index "todos_pkey";
alter table "public"."todos" add constraint "todos_task_check" CHECK ((char_length(task) > 3)) not valid;
alter table "public"."todos" validate constraint "todos_task_check";
alter table "public"."todos" add constraint "todos_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) not valid;
alter table "public"."todos" validate constraint "todos_user_id_fkey";
grant delete on table "public"."todos" to "anon";
grant insert on table "public"."todos" to "anon";
grant references on table "public"."todos" to "anon";
grant select on table "public"."todos" to "anon";
grant trigger on table "public"."todos" to "anon";
grant truncate on table "public"."todos" to "anon";
grant update on table "public"."todos" to "anon";
grant delete on table "public"."todos" to "authenticated";
grant insert on table "public"."todos" to "authenticated";
grant references on table "public"."todos" to "authenticated";
grant select on table "public"."todos" to "authenticated";
grant trigger on table "public"."todos" to "authenticated";
grant truncate on table "public"."todos" to "authenticated";
grant update on table "public"."todos" to "authenticated";
grant delete on table "public"."todos" to "service_role";
grant insert on table "public"."todos" to "service_role";
grant references on table "public"."todos" to "service_role";
grant select on table "public"."todos" to "service_role";
grant trigger on table "public"."todos" to "service_role";
grant truncate on table "public"."todos" to "service_role";
grant update on table "public"."todos" to "service_role";
create policy "Individuals can create todos."
on "public"."todos"
as permissive
for insert
to public
with check ((auth.uid() = user_id));
create policy "Individuals can delete their own todos."
on "public"."todos"
as permissive
for delete
to public
using ((( SELECT auth.uid() AS uid) = user_id));
create policy "Individuals can update their own todos."
on "public"."todos"
as permissive
for update
to public
using ((( SELECT auth.uid() AS uid) = user_id));
create policy "Everyone can read all todos. "
on "public"."todos"
as permissive
for select
to public
using (true);

View File

@@ -0,0 +1,239 @@
insert into "auth"."users"
(
"instance_id",
"id",
"aud",
"role",
"email",
"encrypted_password",
"email_confirmed_at",
"invited_at",
"recovery_token",
"recovery_sent_at",
"last_sign_in_at",
"raw_app_meta_data",
"raw_user_meta_data",
"created_at",
"updated_at"
)
values
(
'00000000-0000-0000-0000-000000000000',
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'authenticated',
'authenticated',
'test@test.com',
'$2a$10$rIvxpnRv5waKFZIQFpMJke079cHjJlqACLZXaONomkc4FaZ4Btlbe',
'2024-02-03 23:38:34.499444+00',
'2024-02-03 23:38:21.438042+00',
'a73278d79e14c427ad5d21509fe88963d258377f77fe9e268d0a92ed',
'2025-01-18 13:35:30.6347+00',
'2025-02-09 14:27:57.653171+00',
'{"provider": "email", "providers": ["email"]}',
'{}',
'2024-02-03 23:38:21.431361+00',
'2025-04-16 20:04:09.697799+00'
);
insert into "public"."todos"
("id", "user_id", "task", "is_complete", "inserted_at")
values
(1, '8999b3f3-6465-4135-9b4f-42c750b90ffb', 'Test task', true, '2024-03-20 14:23:45.000000+00'),
(2, '8999b3f3-6465-4135-9b4f-42c750b90ffb', 'Do laundry', false, '2024-03-15 09:12:30.000000+00'),
(
3,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean bathroom',
true,
'2024-03-28 16:45:22.000000+00'
),
(
4,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Vacuum living room',
false,
'2024-03-10 11:30:15.000000+00'
),
(
5,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Mow the lawn',
true,
'2024-03-22 08:20:40.000000+00'
),
(
6,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Wash dishes',
false,
'2024-03-17 19:05:33.000000+00'
),
(
7,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean kitchen countertops',
true,
'2024-03-25 13:40:18.000000+00'
),
(
8,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Take out trash',
false,
'2024-03-12 07:55:27.000000+00'
),
(
9,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Organize closet',
true,
'2024-03-19 10:15:42.000000+00'
),
(
10,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean windows',
false,
'2024-03-27 15:30:55.000000+00'
),
(
11,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Water plants',
true,
'2024-03-14 12:45:10.000000+00'
),
(
12,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean refrigerator',
false,
'2024-03-23 17:20:35.000000+00'
),
(
13,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Dust furniture',
true,
'2024-03-16 14:10:25.000000+00'
),
(
14,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean oven',
false,
'2024-03-29 09:35:50.000000+00'
),
(
15,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Sweep floors',
true,
'2024-03-11 16:50:15.000000+00'
),
(
16,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean microwave',
false,
'2024-03-24 08:25:40.000000+00'
),
(
17,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Organize pantry',
true,
'2024-03-18 13:40:30.000000+00'
),
(
18,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean shower',
false,
'2024-03-26 11:15:45.000000+00'
),
(
19,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Wipe down baseboards',
true,
'2024-03-13 10:30:20.000000+00'
),
(
20,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean ceiling fans',
false,
'2024-03-21 15:45:55.000000+00'
),
(
21,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Organize garage',
true,
'2024-03-30 07:20:10.000000+00'
),
(
22,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean gutters',
false,
'2024-03-09 14:35:25.000000+00'
),
(23, '8999b3f3-6465-4135-9b4f-42c750b90ffb', 'Wash car', true, '2024-03-31 12:50:40.000000+00'),
(
24,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean pet bedding',
false,
'2024-03-08 09:05:15.000000+00'
),
(
25,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Organize bookshelf',
true,
'2024-03-07 16:20:30.000000+00'
),
(
26,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean air filters',
false,
'2024-03-06 13:35:45.000000+00'
),
(
27,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean light fixtures',
true,
'2024-03-05 10:50:10.000000+00'
),
(
28,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Organize desk',
false,
'2024-03-04 08:05:25.000000+00'
),
(
29,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean patio furniture',
true,
'2024-03-03 15:20:40.000000+00'
),
(
30,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Clean door handles',
false,
'2024-03-02 12:35:55.000000+00'
),
(
31,
'8999b3f3-6465-4135-9b4f-42c750b90ffb',
'Organize medicine cabinet',
true,
'2024-03-01 09:50:20.000000+00'
);

18
pnpm-lock.yaml generated
View File

@@ -1011,28 +1011,34 @@ importers:
apps/ui-library:
dependencies:
'@radix-ui/react-avatar':
specifier: ^1.0.4
specifier: '*'
version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-checkbox':
specifier: '*'
version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.0.2
specifier: '*'
version: 2.0.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-progress':
specifier: ^1.0.3
specifier: '*'
version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot':
specifier: ^1.1.2
specifier: '*'
version: 1.1.2(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-tooltip':
specifier: ^1.0.7
specifier: '*'
version: 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-router/fs-routes':
specifier: ^7.4.0
version: 7.4.0(@react-router/dev@7.4.0(@types/node@22.13.14)(jiti@2.4.2)(react-router@7.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(sass@1.72.0)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.19.3)(typescript@5.5.2)(vite@6.2.6(@types/node@22.13.14)(jiti@2.4.2)(sass@1.72.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.4.5))(yaml@2.4.5))(typescript@5.5.2)
'@supabase/postgrest-js':
specifier: '*'
version: 1.19.2
'@supabase/supa-mdx-lint':
specifier: 0.2.6-alpha
version: 0.2.6-alpha
class-variance-authority:
specifier: ^0.6.0
specifier: '*'
version: 0.6.1
common:
specifier: workspace:*