Lit Example: Pagination

import { LitElement, html } from 'lit'
import {
  QueryClient,
  QueryClientProvider,
  createMutationController,
  createQueryController,
  keepPreviousData,
} from '@tanstack/lit-query'
import {
  armNextProjectMutationFailureOnServer,
  createProjectOnServer,
  fetchProjectsPage,
  projectsQueryKey,
  resetProjectsApiState,
  toggleProjectFavoriteOnServer,
} from './api'
import type {
  CreateQueryOptions,
  MutationResultAccessor,
  QueryKey,
  QueryResultAccessor,
} from '@tanstack/lit-query'
import type {
  CreateProjectInput,
  Project,
  ProjectsPageResponse,
  ToggleProjectFavoriteInput,
} from './api'

type ProjectsCacheSnapshot = Array<[QueryKey, ProjectsPageResponse | undefined]>
type FavoriteMutationContext = {
  snapshots: ProjectsCacheSnapshot
}

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false,
      staleTime: 5_000,
    },
    mutations: {
      retry: false,
    },
  },
})

class PaginationQueryProvider extends QueryClientProvider {
  constructor() {
    super()
    this.client = queryClient
  }

  protected override createRenderRoot(): HTMLElement | DocumentFragment {
    return this
  }
}

customElements.define('pagination-query-provider', PaginationQueryProvider)

class PaginationDemo extends LitElement {
  static properties = {
    page: { state: true },
    delayMs: { state: true },
    forceErrorMode: { state: true },
    prefetchStatus: { state: true },
    resetError: { state: true },
    draftName: { state: true },
    draftOwner: { state: true },
    mutationControlStatus: { state: true },
    mutationControlError: { state: true },
  }

  private page = 1
  private delayMs = 250
  private forceErrorMode = false
  private prefetchStatus = 'idle'
  private resetError: string | undefined
  private draftName = 'Platform Rollout'
  private draftOwner = 'Team 6'
  private mutationControlStatus = 'idle'
  private mutationControlError: string | undefined
  private lastAutoPrefetchPage = 0
  private readonly projectsQueryOptions: CreateQueryOptions<
    ProjectsPageResponse,
    Error
  >
  private readonly projectsQuery: QueryResultAccessor<
    ProjectsPageResponse,
    Error
  >
  private readonly createProjectMutation: MutationResultAccessor<
    Project,
    Error,
    CreateProjectInput,
    unknown
  >
  private readonly favoriteMutation: MutationResultAccessor<
    Project,
    Error,
    ToggleProjectFavoriteInput,
    FavoriteMutationContext
  >

  constructor() {
    super()

    this.projectsQueryOptions = {
      queryKey: projectsQueryKey(this.page, this.delayMs, this.forceErrorMode),
      queryFn: () =>
        fetchProjectsPage(this.page, this.delayMs, this.forceErrorMode),
      placeholderData: keepPreviousData,
    }

    this.projectsQuery = createQueryController<ProjectsPageResponse, Error>(
      this,
      this.projectsQueryOptions,
    )

    this.createProjectMutation = createMutationController<
      Project,
      Error,
      CreateProjectInput
    >(
      this,
      {
        mutationKey: ['create-project'],
        mutationFn: async (input) => {
          const response = await createProjectOnServer(input)
          return response.project
        },
        onMutate: () => {
          this.mutationControlStatus = 'idle'
          this.mutationControlError = undefined
        },
        onSuccess: async () => {
          this.page = 1
          this.lastAutoPrefetchPage = 0
          this.prefetchStatus = 'idle'
          this.draftName = ''
          this.draftOwner = 'Team 6'
          this.syncProjectsQueryOptions()
          await queryClient.invalidateQueries({
            queryKey: ['projects'],
            refetchType: 'none',
          })
          await this.projectsQuery.refetch()
        },
      },
      queryClient,
    )

    this.favoriteMutation = createMutationController<
      Project,
      Error,
      ToggleProjectFavoriteInput,
      FavoriteMutationContext
    >(
      this,
      {
        mutationKey: ['toggle-project-favorite'],
        mutationFn: async (input) => {
          const response = await toggleProjectFavoriteOnServer(input)
          return response.project
        },
        onMutate: async (variables) => {
          this.mutationControlStatus = 'idle'
          this.mutationControlError = undefined
          await queryClient.cancelQueries({ queryKey: ['projects'] })

          const snapshots = queryClient.getQueriesData<ProjectsPageResponse>({
            queryKey: ['projects'],
          })

          for (const [key, existing] of snapshots) {
            if (!existing) {
              continue
            }

            queryClient.setQueryData<ProjectsPageResponse>(key, {
              ...existing,
              projects: existing.projects.map((project) =>
                project.id === variables.id
                  ? { ...project, isFavorite: variables.isFavorite }
                  : project,
              ),
            })
          }

          return { snapshots }
        },
        onError: (_error, _variables, context) => {
          for (const [key, snapshot] of context?.snapshots ?? []) {
            queryClient.setQueryData(key, snapshot)
          }
        },
        onSettled: async () => {
          await queryClient.invalidateQueries({ queryKey: ['projects'] })
        },
      },
      queryClient,
    )
  }

  protected override createRenderRoot(): HTMLElement | DocumentFragment {
    return this
  }

  override updated(): void {
    this.maybePrefetchNextPage()
  }

  private syncProjectsQueryOptions(): void {
    this.projectsQueryOptions.queryKey = projectsQueryKey(
      this.page,
      this.delayMs,
      this.forceErrorMode,
    )
    this.projectsQueryOptions.queryFn = () =>
      fetchProjectsPage(this.page, this.delayMs, this.forceErrorMode)
  }

  private refetchForCurrentState(): void {
    this.syncProjectsQueryOptions()
    void this.projectsQuery.refetch()
  }

  private async maybePrefetchNextPage(): Promise<void> {
    const query = this.projectsQuery()
    const currentData = query.data

    if (!currentData || query.isPlaceholderData || !currentData.hasMore) {
      return
    }

    if (this.lastAutoPrefetchPage === currentData.page) {
      return
    }

    this.lastAutoPrefetchPage = currentData.page
    await this.prefetchPage(currentData.page + 1)
  }

  private onDelayInput(event: Event): void {
    const target = event.target as HTMLInputElement
    const nextValue = Number.parseInt(target.value, 10)

    if (!Number.isInteger(nextValue) || nextValue < 0) {
      return
    }

    this.delayMs = nextValue
    this.refetchForCurrentState()
  }

  private onErrorModeToggle(event: Event): void {
    const target = event.target as HTMLInputElement
    this.forceErrorMode = target.checked
    this.refetchForCurrentState()
  }

  private onDraftNameInput(event: Event): void {
    const target = event.target as HTMLInputElement
    this.draftName = target.value
  }

  private onDraftOwnerInput(event: Event): void {
    const target = event.target as HTMLInputElement
    this.draftOwner = target.value
  }

  private async prefetchPage(targetPage: number): Promise<void> {
    this.prefetchStatus = `pending:${targetPage}`

    try {
      await queryClient.prefetchQuery({
        queryKey: projectsQueryKey(
          targetPage,
          this.delayMs,
          this.forceErrorMode,
        ),
        queryFn: () =>
          fetchProjectsPage(targetPage, this.delayMs, this.forceErrorMode),
      })
      this.prefetchStatus = `ready:${targetPage}`
    } catch (error) {
      this.prefetchStatus = `error:${String(error)}`
    }
  }

  private async prefetchNext(): Promise<void> {
    const query = this.projectsQuery()
    const currentData = query.data

    if (!currentData?.hasMore) {
      this.prefetchStatus = 'skipped:no-next-page'
      return
    }

    await this.prefetchPage(currentData.page + 1)
  }

  private goToPreviousPage(): void {
    if (this.page > 1) {
      this.page -= 1
      this.refetchForCurrentState()
    }
  }

  private goToNextPage(): void {
    const currentData = this.projectsQuery().data
    if (!currentData?.hasMore) {
      return
    }

    this.page += 1
    this.refetchForCurrentState()
  }

  private async resetDemoState(): Promise<void> {
    this.resetError = undefined

    try {
      await resetProjectsApiState()
      this.page = 1
      this.delayMs = 250
      this.forceErrorMode = false
      this.prefetchStatus = 'idle'
      this.resetError = undefined
      this.draftName = 'Platform Rollout'
      this.draftOwner = 'Team 6'
      this.mutationControlStatus = 'idle'
      this.mutationControlError = undefined
      this.lastAutoPrefetchPage = 0
      this.syncProjectsQueryOptions()
      this.createProjectMutation.reset()
      this.favoriteMutation.reset()
      await queryClient.resetQueries({ queryKey: ['projects'] })
      await this.projectsQuery.refetch()
    } catch (error) {
      this.resetError = String(error)
    }
  }

  private submitCreateProject(): void {
    const name = this.draftName.trim()
    const owner = this.draftOwner.trim()

    if (!name || !owner) {
      return
    }

    this.createProjectMutation.mutate({ name, owner })
  }

  private toggleFavorite(project: Project): void {
    this.favoriteMutation.mutate({
      id: project.id,
      isFavorite: !project.isFavorite,
    })
  }

  private async armNextMutationFailure(): Promise<void> {
    this.mutationControlError = undefined

    try {
      await armNextProjectMutationFailureOnServer()
      this.mutationControlStatus = 'armed'
    } catch (error) {
      this.mutationControlStatus = 'error'
      this.mutationControlError = String(error)
    }
  }

  render() {
    const query = this.projectsQuery()
    const projects = query.data?.projects ?? []
    const hasMore = query.data?.hasMore ?? false
    const createProject = this.createProjectMutation()
    const favoriteProject = this.favoriteMutation()

    return html`
      <main>
        <h1>TanStack Lit Query Pagination Demo</h1>
        <p>
          Pagination + mutation demo with optimistic favorite toggles,
          invalidation, and deterministic server failures.
        </p>

        <section>
          <div data-testid="query-status">query: ${query.status}</div>
          <div data-testid="is-fetching">
            isFetching: ${query.isFetching ? 'yes' : 'no'}
          </div>
          <div data-testid="is-placeholder">
            isPlaceholderData: ${query.isPlaceholderData ? 'yes' : 'no'}
          </div>
          <div data-testid="current-page">page: ${this.page}</div>
          <div data-testid="response-page">
            response-page: ${query.data?.page ?? '-'}
          </div>
          <div data-testid="has-more">has-more: ${hasMore ? 'yes' : 'no'}</div>
          <div data-testid="total-projects">
            total-projects: ${query.data?.totalProjects ?? 0}
          </div>
          <div data-testid="total-request-count">
            total-requests: ${query.data?.requestMeta.totalRequestCount ?? 0}
          </div>
          <div data-testid="page-request-count">
            page-requests: ${query.data?.requestMeta.pageRequestCount ?? 0}
          </div>
          <div data-testid="total-mutation-count">
            total-mutations: ${query.data?.requestMeta.totalMutationCount ?? 0}
          </div>
          <div data-testid="prefetch-status">
            prefetch: ${this.prefetchStatus}
          </div>
        </section>

        ${query.isError
          ? html`<p data-testid="query-error">${String(query.error)}</p>`
          : null}
        ${this.resetError
          ? html`<p data-testid="reset-error">${this.resetError}</p>`
          : null}

        <section>
          <label for="delayInput">Delay (ms)</label>
          <input
            id="delayInput"
            data-testid="delay-input"
            type="number"
            min="0"
            .value=${String(this.delayMs)}
            @input=${this.onDelayInput}
          />

          <label for="errorModeToggle">Force query error</label>
          <input
            id="errorModeToggle"
            data-testid="force-error-toggle"
            type="checkbox"
            .checked=${this.forceErrorMode}
            @change=${this.onErrorModeToggle}
          />

          <button
            data-testid="reset-demo-state"
            @click=${() => this.resetDemoState()}
          >
            Reset Demo State
          </button>
          <button
            data-testid="refetch"
            @click=${() => this.projectsQuery.refetch()}
          >
            Refetch
          </button>
          <button
            data-testid="prefetch-next"
            @click=${() => this.prefetchNext()}
          >
            Prefetch Next
          </button>
          <button
            data-testid="arm-mutation-error"
            @click=${() => this.armNextMutationFailure()}
          >
            Fail Next Mutation
          </button>
          <div data-testid="mutation-control-status">
            mutation-control: ${this.mutationControlStatus}
          </div>
          ${this.mutationControlError
            ? html`<div data-testid="mutation-control-error">
                ${this.mutationControlError}
              </div>`
            : null}
        </section>

        <section>
          <div data-testid="create-mutation-status">
            create-mutation: ${createProject.status}
          </div>
          ${createProject.isError
            ? html`<div data-testid="create-mutation-error">
                ${String(createProject.error)}
              </div>`
            : null}

          <label for="projectNameInput">Project name</label>
          <input
            id="projectNameInput"
            data-testid="project-name-input"
            .value=${this.draftName}
            @input=${this.onDraftNameInput}
          />

          <label for="projectOwnerInput">Owner</label>
          <input
            id="projectOwnerInput"
            data-testid="project-owner-input"
            .value=${this.draftOwner}
            @input=${this.onDraftOwnerInput}
          />

          <button
            data-testid="create-project"
            ?disabled=${createProject.isPending}
            @click=${() => this.submitCreateProject()}
          >
            Create Project
          </button>
        </section>

        <section>
          <div data-testid="favorite-mutation-status">
            favorite-mutation: ${favoriteProject.status}
          </div>
          ${favoriteProject.isError
            ? html`<div data-testid="favorite-mutation-error">
                ${String(favoriteProject.error)}
              </div>`
            : null}
        </section>

        <section>
          <button
            data-testid="previous-page"
            ?disabled=${this.page === 1 || query.isPlaceholderData}
            @click=${() => this.goToPreviousPage()}
          >
            Previous
          </button>
          <button
            data-testid="next-page"
            ?disabled=${!hasMore || query.isPlaceholderData}
            @click=${() => this.goToNextPage()}
          >
            Next
          </button>
        </section>

        <ul data-testid="project-list">
          ${projects.map(
            (project) => html`
              <li data-testid="project-item">
                <span data-testid="project-name"
                  >${project.id}: ${project.name} (${project.owner})</span
                >
                <span data-testid="project-favorite-state"
                  >${project.isFavorite ? 'favorite' : 'standard'}</span
                >
                <button
                  data-testid="toggle-favorite"
                  ?disabled=${favoriteProject.isPending}
                  @click=${() => this.toggleFavorite(project)}
                >
                  ${project.isFavorite ? 'Unfavorite' : 'Favorite'}
                </button>
              </li>
            `,
          )}
        </ul>
      </main>
    `
  }
}

customElements.define('pagination-demo', PaginationDemo)

class PaginationDemoRoot extends LitElement {
  protected override createRenderRoot(): HTMLElement | DocumentFragment {
    return this
  }

  render() {
    return html`
      <pagination-query-provider>
        <pagination-demo></pagination-demo>
      </pagination-query-provider>
    `
  }
}

customElements.define('pagination-demo-root', PaginationDemoRoot)
import { LitElement, html } from 'lit'
import {
  QueryClient,
  QueryClientProvider,
  createMutationController,
  createQueryController,
  keepPreviousData,
} from '@tanstack/lit-query'
import {
  armNextProjectMutationFailureOnServer,
  createProjectOnServer,
  fetchProjectsPage,
  projectsQueryKey,
  resetProjectsApiState,
  toggleProjectFavoriteOnServer,
} from './api'
import type {
  CreateQueryOptions,
  MutationResultAccessor,
  QueryKey,
  QueryResultAccessor,
} from '@tanstack/lit-query'
import type {
  CreateProjectInput,
  Project,
  ProjectsPageResponse,
  ToggleProjectFavoriteInput,
} from './api'

type ProjectsCacheSnapshot = Array<[QueryKey, ProjectsPageResponse | undefined]>
type FavoriteMutationContext = {
  snapshots: ProjectsCacheSnapshot
}

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false,
      staleTime: 5_000,
    },
    mutations: {
      retry: false,
    },
  },
})

class PaginationQueryProvider extends QueryClientProvider {
  constructor() {
    super()
    this.client = queryClient
  }

  protected override createRenderRoot(): HTMLElement | DocumentFragment {
    return this
  }
}

customElements.define('pagination-query-provider', PaginationQueryProvider)

class PaginationDemo extends LitElement {
  static properties = {
    page: { state: true },
    delayMs: { state: true },
    forceErrorMode: { state: true },
    prefetchStatus: { state: true },
    resetError: { state: true },
    draftName: { state: true },
    draftOwner: { state: true },
    mutationControlStatus: { state: true },
    mutationControlError: { state: true },
  }

  private page = 1
  private delayMs = 250
  private forceErrorMode = false
  private prefetchStatus = 'idle'
  private resetError: string | undefined
  private draftName = 'Platform Rollout'
  private draftOwner = 'Team 6'
  private mutationControlStatus = 'idle'
  private mutationControlError: string | undefined
  private lastAutoPrefetchPage = 0
  private readonly projectsQueryOptions: CreateQueryOptions<
    ProjectsPageResponse,
    Error
  >
  private readonly projectsQuery: QueryResultAccessor<
    ProjectsPageResponse,
    Error
  >
  private readonly createProjectMutation: MutationResultAccessor<
    Project,
    Error,
    CreateProjectInput,
    unknown
  >
  private readonly favoriteMutation: MutationResultAccessor<
    Project,
    Error,
    ToggleProjectFavoriteInput,
    FavoriteMutationContext
  >

  constructor() {
    super()

    this.projectsQueryOptions = {
      queryKey: projectsQueryKey(this.page, this.delayMs, this.forceErrorMode),
      queryFn: () =>
        fetchProjectsPage(this.page, this.delayMs, this.forceErrorMode),
      placeholderData: keepPreviousData,
    }

    this.projectsQuery = createQueryController<ProjectsPageResponse, Error>(
      this,
      this.projectsQueryOptions,
    )

    this.createProjectMutation = createMutationController<
      Project,
      Error,
      CreateProjectInput
    >(
      this,
      {
        mutationKey: ['create-project'],
        mutationFn: async (input) => {
          const response = await createProjectOnServer(input)
          return response.project
        },
        onMutate: () => {
          this.mutationControlStatus = 'idle'
          this.mutationControlError = undefined
        },
        onSuccess: async () => {
          this.page = 1
          this.lastAutoPrefetchPage = 0
          this.prefetchStatus = 'idle'
          this.draftName = ''
          this.draftOwner = 'Team 6'
          this.syncProjectsQueryOptions()
          await queryClient.invalidateQueries({
            queryKey: ['projects'],
            refetchType: 'none',
          })
          await this.projectsQuery.refetch()
        },
      },
      queryClient,
    )

    this.favoriteMutation = createMutationController<
      Project,
      Error,
      ToggleProjectFavoriteInput,
      FavoriteMutationContext
    >(
      this,
      {
        mutationKey: ['toggle-project-favorite'],
        mutationFn: async (input) => {
          const response = await toggleProjectFavoriteOnServer(input)
          return response.project
        },
        onMutate: async (variables) => {
          this.mutationControlStatus = 'idle'
          this.mutationControlError = undefined
          await queryClient.cancelQueries({ queryKey: ['projects'] })

          const snapshots = queryClient.getQueriesData<ProjectsPageResponse>({
            queryKey: ['projects'],
          })

          for (const [key, existing] of snapshots) {
            if (!existing) {
              continue
            }

            queryClient.setQueryData<ProjectsPageResponse>(key, {
              ...existing,
              projects: existing.projects.map((project) =>
                project.id === variables.id
                  ? { ...project, isFavorite: variables.isFavorite }
                  : project,
              ),
            })
          }

          return { snapshots }
        },
        onError: (_error, _variables, context) => {
          for (const [key, snapshot] of context?.snapshots ?? []) {
            queryClient.setQueryData(key, snapshot)
          }
        },
        onSettled: async () => {
          await queryClient.invalidateQueries({ queryKey: ['projects'] })
        },
      },
      queryClient,
    )
  }

  protected override createRenderRoot(): HTMLElement | DocumentFragment {
    return this
  }

  override updated(): void {
    this.maybePrefetchNextPage()
  }

  private syncProjectsQueryOptions(): void {
    this.projectsQueryOptions.queryKey = projectsQueryKey(
      this.page,
      this.delayMs,
      this.forceErrorMode,
    )
    this.projectsQueryOptions.queryFn = () =>
      fetchProjectsPage(this.page, this.delayMs, this.forceErrorMode)
  }

  private refetchForCurrentState(): void {
    this.syncProjectsQueryOptions()
    void this.projectsQuery.refetch()
  }

  private async maybePrefetchNextPage(): Promise<void> {
    const query = this.projectsQuery()
    const currentData = query.data

    if (!currentData || query.isPlaceholderData || !currentData.hasMore) {
      return
    }

    if (this.lastAutoPrefetchPage === currentData.page) {
      return
    }

    this.lastAutoPrefetchPage = currentData.page
    await this.prefetchPage(currentData.page + 1)
  }

  private onDelayInput(event: Event): void {
    const target = event.target as HTMLInputElement
    const nextValue = Number.parseInt(target.value, 10)

    if (!Number.isInteger(nextValue) || nextValue < 0) {
      return
    }

    this.delayMs = nextValue
    this.refetchForCurrentState()
  }

  private onErrorModeToggle(event: Event): void {
    const target = event.target as HTMLInputElement
    this.forceErrorMode = target.checked
    this.refetchForCurrentState()
  }

  private onDraftNameInput(event: Event): void {
    const target = event.target as HTMLInputElement
    this.draftName = target.value
  }

  private onDraftOwnerInput(event: Event): void {
    const target = event.target as HTMLInputElement
    this.draftOwner = target.value
  }

  private async prefetchPage(targetPage: number): Promise<void> {
    this.prefetchStatus = `pending:${targetPage}`

    try {
      await queryClient.prefetchQuery({
        queryKey: projectsQueryKey(
          targetPage,
          this.delayMs,
          this.forceErrorMode,
        ),
        queryFn: () =>
          fetchProjectsPage(targetPage, this.delayMs, this.forceErrorMode),
      })
      this.prefetchStatus = `ready:${targetPage}`
    } catch (error) {
      this.prefetchStatus = `error:${String(error)}`
    }
  }

  private async prefetchNext(): Promise<void> {
    const query = this.projectsQuery()
    const currentData = query.data

    if (!currentData?.hasMore) {
      this.prefetchStatus = 'skipped:no-next-page'
      return
    }

    await this.prefetchPage(currentData.page + 1)
  }

  private goToPreviousPage(): void {
    if (this.page > 1) {
      this.page -= 1
      this.refetchForCurrentState()
    }
  }

  private goToNextPage(): void {
    const currentData = this.projectsQuery().data
    if (!currentData?.hasMore) {
      return
    }

    this.page += 1
    this.refetchForCurrentState()
  }

  private async resetDemoState(): Promise<void> {
    this.resetError = undefined

    try {
      await resetProjectsApiState()
      this.page = 1
      this.delayMs = 250
      this.forceErrorMode = false
      this.prefetchStatus = 'idle'
      this.resetError = undefined
      this.draftName = 'Platform Rollout'
      this.draftOwner = 'Team 6'
      this.mutationControlStatus = 'idle'
      this.mutationControlError = undefined
      this.lastAutoPrefetchPage = 0
      this.syncProjectsQueryOptions()
      this.createProjectMutation.reset()
      this.favoriteMutation.reset()
      await queryClient.resetQueries({ queryKey: ['projects'] })
      await this.projectsQuery.refetch()
    } catch (error) {
      this.resetError = String(error)
    }
  }

  private submitCreateProject(): void {
    const name = this.draftName.trim()
    const owner = this.draftOwner.trim()

    if (!name || !owner) {
      return
    }

    this.createProjectMutation.mutate({ name, owner })
  }

  private toggleFavorite(project: Project): void {
    this.favoriteMutation.mutate({
      id: project.id,
      isFavorite: !project.isFavorite,
    })
  }

  private async armNextMutationFailure(): Promise<void> {
    this.mutationControlError = undefined

    try {
      await armNextProjectMutationFailureOnServer()
      this.mutationControlStatus = 'armed'
    } catch (error) {
      this.mutationControlStatus = 'error'
      this.mutationControlError = String(error)
    }
  }

  render() {
    const query = this.projectsQuery()
    const projects = query.data?.projects ?? []
    const hasMore = query.data?.hasMore ?? false
    const createProject = this.createProjectMutation()
    const favoriteProject = this.favoriteMutation()

    return html`
      <main>
        <h1>TanStack Lit Query Pagination Demo</h1>
        <p>
          Pagination + mutation demo with optimistic favorite toggles,
          invalidation, and deterministic server failures.
        </p>

        <section>
          <div data-testid="query-status">query: ${query.status}</div>
          <div data-testid="is-fetching">
            isFetching: ${query.isFetching ? 'yes' : 'no'}
          </div>
          <div data-testid="is-placeholder">
            isPlaceholderData: ${query.isPlaceholderData ? 'yes' : 'no'}
          </div>
          <div data-testid="current-page">page: ${this.page}</div>
          <div data-testid="response-page">
            response-page: ${query.data?.page ?? '-'}
          </div>
          <div data-testid="has-more">has-more: ${hasMore ? 'yes' : 'no'}</div>
          <div data-testid="total-projects">
            total-projects: ${query.data?.totalProjects ?? 0}
          </div>
          <div data-testid="total-request-count">
            total-requests: ${query.data?.requestMeta.totalRequestCount ?? 0}
          </div>
          <div data-testid="page-request-count">
            page-requests: ${query.data?.requestMeta.pageRequestCount ?? 0}
          </div>
          <div data-testid="total-mutation-count">
            total-mutations: ${query.data?.requestMeta.totalMutationCount ?? 0}
          </div>
          <div data-testid="prefetch-status">
            prefetch: ${this.prefetchStatus}
          </div>
        </section>

        ${query.isError
          ? html`<p data-testid="query-error">${String(query.error)}</p>`
          : null}
        ${this.resetError
          ? html`<p data-testid="reset-error">${this.resetError}</p>`
          : null}

        <section>
          <label for="delayInput">Delay (ms)</label>
          <input
            id="delayInput"
            data-testid="delay-input"
            type="number"
            min="0"
            .value=${String(this.delayMs)}
            @input=${this.onDelayInput}
          />

          <label for="errorModeToggle">Force query error</label>
          <input
            id="errorModeToggle"
            data-testid="force-error-toggle"
            type="checkbox"
            .checked=${this.forceErrorMode}
            @change=${this.onErrorModeToggle}
          />

          <button
            data-testid="reset-demo-state"
            @click=${() => this.resetDemoState()}
          >
            Reset Demo State
          </button>
          <button
            data-testid="refetch"
            @click=${() => this.projectsQuery.refetch()}
          >
            Refetch
          </button>
          <button
            data-testid="prefetch-next"
            @click=${() => this.prefetchNext()}
          >
            Prefetch Next
          </button>
          <button
            data-testid="arm-mutation-error"
            @click=${() => this.armNextMutationFailure()}
          >
            Fail Next Mutation
          </button>
          <div data-testid="mutation-control-status">
            mutation-control: ${this.mutationControlStatus}
          </div>
          ${this.mutationControlError
            ? html`<div data-testid="mutation-control-error">
                ${this.mutationControlError}
              </div>`
            : null}
        </section>

        <section>
          <div data-testid="create-mutation-status">
            create-mutation: ${createProject.status}
          </div>
          ${createProject.isError
            ? html`<div data-testid="create-mutation-error">
                ${String(createProject.error)}
              </div>`
            : null}

          <label for="projectNameInput">Project name</label>
          <input
            id="projectNameInput"
            data-testid="project-name-input"
            .value=${this.draftName}
            @input=${this.onDraftNameInput}
          />

          <label for="projectOwnerInput">Owner</label>
          <input
            id="projectOwnerInput"
            data-testid="project-owner-input"
            .value=${this.draftOwner}
            @input=${this.onDraftOwnerInput}
          />

          <button
            data-testid="create-project"
            ?disabled=${createProject.isPending}
            @click=${() => this.submitCreateProject()}
          >
            Create Project
          </button>
        </section>

        <section>
          <div data-testid="favorite-mutation-status">
            favorite-mutation: ${favoriteProject.status}
          </div>
          ${favoriteProject.isError
            ? html`<div data-testid="favorite-mutation-error">
                ${String(favoriteProject.error)}
              </div>`
            : null}
        </section>

        <section>
          <button
            data-testid="previous-page"
            ?disabled=${this.page === 1 || query.isPlaceholderData}
            @click=${() => this.goToPreviousPage()}
          >
            Previous
          </button>
          <button
            data-testid="next-page"
            ?disabled=${!hasMore || query.isPlaceholderData}
            @click=${() => this.goToNextPage()}
          >
            Next
          </button>
        </section>

        <ul data-testid="project-list">
          ${projects.map(
            (project) => html`
              <li data-testid="project-item">
                <span data-testid="project-name"
                  >${project.id}: ${project.name} (${project.owner})</span
                >
                <span data-testid="project-favorite-state"
                  >${project.isFavorite ? 'favorite' : 'standard'}</span
                >
                <button
                  data-testid="toggle-favorite"
                  ?disabled=${favoriteProject.isPending}
                  @click=${() => this.toggleFavorite(project)}
                >
                  ${project.isFavorite ? 'Unfavorite' : 'Favorite'}
                </button>
              </li>
            `,
          )}
        </ul>
      </main>
    `
  }
}

customElements.define('pagination-demo', PaginationDemo)

class PaginationDemoRoot extends LitElement {
  protected override createRenderRoot(): HTMLElement | DocumentFragment {
    return this
  }

  render() {
    return html`
      <pagination-query-provider>
        <pagination-demo></pagination-demo>
      </pagination-query-provider>
    `
  }
}

customElements.define('pagination-demo-root', PaginationDemoRoot)