snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
8
examples/lit/basic/CHANGELOG.md
Normal file
8
examples/lit/basic/CHANGELOG.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# @tanstack/query-example-lit-basic
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4082894`](https://github.com/TanStack/query/commit/4082894509f31376ebeb8514cc3e167bbbfc7c46)]:
|
||||
- @tanstack/lit-query@0.2.0
|
||||
5
examples/lit/basic/README.md
Normal file
5
examples/lit/basic/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Example
|
||||
|
||||
To run this example from the repo root:
|
||||
|
||||
- `pnpm --dir examples/lit/basic run dev`
|
||||
12
examples/lit/basic/basic-query.html
Normal file
12
examples/lit/basic/basic-query.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lit Query Basic Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<basic-query-root></basic-query-root>
|
||||
<script type="module" src="/src/basic-query.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
examples/lit/basic/config/port.d.ts
vendored
Normal file
1
examples/lit/basic/config/port.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export const DEMO_PORT: number
|
||||
22
examples/lit/basic/config/port.js
Normal file
22
examples/lit/basic/config/port.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const DEFAULT_DEMO_PORT = 4173
|
||||
const envPort = process.env.DEMO_PORT
|
||||
|
||||
function resolvePort() {
|
||||
if (!envPort) {
|
||||
return DEFAULT_DEMO_PORT
|
||||
}
|
||||
|
||||
const parsedPort = Number.parseInt(envPort, 10)
|
||||
const isValidPort =
|
||||
Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535
|
||||
|
||||
if (!isValidPort) {
|
||||
throw new Error(
|
||||
`Invalid DEMO_PORT "${envPort}". Expected an integer between 1 and 65535.`,
|
||||
)
|
||||
}
|
||||
|
||||
return parsedPort
|
||||
}
|
||||
|
||||
export const DEMO_PORT = resolvePort()
|
||||
12
examples/lit/basic/index.html
Normal file
12
examples/lit/basic/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TanStack Lit Query E2E Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<demo-root></demo-root>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
examples/lit/basic/lifecycle-contract.html
Normal file
12
examples/lit/basic/lifecycle-contract.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TanStack Lit Query Lifecycle Contract Fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<lifecycle-contract-root></lifecycle-contract-root>
|
||||
<script type="module" src="/src/lifecycle-contract.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
examples/lit/basic/mutation.html
Normal file
12
examples/lit/basic/mutation.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lit Query Mutation Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<mutation-example-root></mutation-example-root>
|
||||
<script type="module" src="/src/mutation.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
examples/lit/basic/package.json
Normal file
19
examples/lit/basic/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@tanstack/query-example-lit-basic",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/lit-query": "^0.2.20",
|
||||
"@tanstack/query-core": "^5.102.8",
|
||||
"lit": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.8.3",
|
||||
"vite": "^6.4.1"
|
||||
}
|
||||
}
|
||||
90
examples/lit/basic/src/basic-query.ts
Normal file
90
examples/lit/basic/src/basic-query.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { LitElement, html } from 'lit'
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
createQueryController,
|
||||
} from '@tanstack/lit-query'
|
||||
import { fetchTodosFromServer, resetTodoApi } from './todoApi'
|
||||
import type { TodosResponse } from './todoApi'
|
||||
|
||||
resetTodoApi()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
class BasicQueryProvider extends QueryClientProvider {
|
||||
constructor() {
|
||||
super()
|
||||
this.client = queryClient
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('basic-query-provider', BasicQueryProvider)
|
||||
|
||||
class BasicQueryExample extends LitElement {
|
||||
private readonly todos = createQueryController<TodosResponse, Error>(this, {
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodosFromServer,
|
||||
})
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
|
||||
render() {
|
||||
const query = this.todos()
|
||||
return html`
|
||||
<main>
|
||||
<h1>Basic Query Example</h1>
|
||||
<p data-testid="basic-query-status">
|
||||
Status: <strong>${query.status}</strong>
|
||||
</p>
|
||||
<button
|
||||
data-testid="basic-refetch"
|
||||
@click=${() => this.todos.refetch()}
|
||||
>
|
||||
Refetch
|
||||
</button>
|
||||
|
||||
${query.isPending
|
||||
? html`<p data-testid="basic-loading">Loading...</p>`
|
||||
: null}
|
||||
${query.isError
|
||||
? html`<p data-testid="basic-error">Error: ${String(query.error)}</p>`
|
||||
: null}
|
||||
|
||||
<ul data-testid="basic-todo-list">
|
||||
${(query.data?.items ?? []).map(
|
||||
(todo) =>
|
||||
html`<li data-testid="basic-todo-item">${todo.title}</li>`,
|
||||
)}
|
||||
</ul>
|
||||
</main>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('basic-query-example', BasicQueryExample)
|
||||
|
||||
class BasicQueryRoot extends LitElement {
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<basic-query-provider>
|
||||
<basic-query-example></basic-query-example>
|
||||
</basic-query-provider>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('basic-query-root', BasicQueryRoot)
|
||||
223
examples/lit/basic/src/lifecycle-contract.ts
Normal file
223
examples/lit/basic/src/lifecycle-contract.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { LitElement, html } from 'lit'
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
createQueryController,
|
||||
} from '@tanstack/lit-query'
|
||||
|
||||
type ContractProbeData = {
|
||||
provider: 'provider-a' | 'provider-b'
|
||||
payload: string
|
||||
}
|
||||
|
||||
type ContractTarget = 'orphan' | 'provider-a' | 'provider-b'
|
||||
|
||||
const contractQueryKey = ['lifecycle-contract', 'provider-binding'] as const
|
||||
let contractConsumerInstanceCount = 0
|
||||
|
||||
function createContractClient(data: ContractProbeData): QueryClient {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
client.setQueryData(contractQueryKey, data)
|
||||
return client
|
||||
}
|
||||
|
||||
const contractClientA = createContractClient({
|
||||
provider: 'provider-a',
|
||||
payload: 'provider-a cache',
|
||||
})
|
||||
|
||||
const contractClientB = createContractClient({
|
||||
provider: 'provider-b',
|
||||
payload: 'provider-b cache',
|
||||
})
|
||||
|
||||
class ContractProviderA extends QueryClientProvider {
|
||||
constructor() {
|
||||
super()
|
||||
this.client = contractClientA
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('contract-provider-a')) {
|
||||
customElements.define('contract-provider-a', ContractProviderA)
|
||||
}
|
||||
|
||||
class ContractProviderB extends QueryClientProvider {
|
||||
constructor() {
|
||||
super()
|
||||
this.client = contractClientB
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('contract-provider-b')) {
|
||||
customElements.define('contract-provider-b', ContractProviderB)
|
||||
}
|
||||
|
||||
class LifecycleContractConsumer extends LitElement {
|
||||
private readonly instanceId = ++contractConsumerInstanceCount
|
||||
|
||||
private readonly query = createQueryController<ContractProbeData, Error>(
|
||||
this,
|
||||
{
|
||||
queryKey: contractQueryKey,
|
||||
queryFn: () => {
|
||||
throw new Error(
|
||||
'Lifecycle contract fixture unexpectedly fetched from queryFn.',
|
||||
)
|
||||
},
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
)
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
|
||||
private renderQueryState() {
|
||||
try {
|
||||
const query = this.query()
|
||||
|
||||
return html`
|
||||
<div data-testid="contract-query-status">query: ${query.status}</div>
|
||||
<div data-testid="contract-provider-value">
|
||||
provider: ${query.data?.provider ?? 'none'}
|
||||
</div>
|
||||
<div data-testid="contract-payload">
|
||||
payload: ${query.data?.payload ?? 'none'}
|
||||
</div>
|
||||
<div data-testid="contract-error">
|
||||
error: ${query.error ? String(query.error) : 'none'}
|
||||
</div>
|
||||
`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return html`
|
||||
<div data-testid="contract-query-status">query: missing-client</div>
|
||||
<div data-testid="contract-provider-value">provider: none</div>
|
||||
<div data-testid="contract-payload">payload: none</div>
|
||||
<div data-testid="contract-error">error: ${message}</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div data-testid="contract-instance-id">instance: ${this.instanceId}</div>
|
||||
${this.renderQueryState()}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('lifecycle-contract-consumer')) {
|
||||
customElements.define(
|
||||
'lifecycle-contract-consumer',
|
||||
LifecycleContractConsumer,
|
||||
)
|
||||
}
|
||||
|
||||
class LifecycleContractRoot extends LitElement {
|
||||
static properties = {
|
||||
currentTarget: { state: true },
|
||||
}
|
||||
|
||||
private currentTarget: ContractTarget = 'orphan'
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
this.moveConsumerTo('orphan')
|
||||
}
|
||||
|
||||
moveConsumerTo(target: ContractTarget): void {
|
||||
const consumer = this.ensureConsumer()
|
||||
const destination = this.getContainer(target)
|
||||
destination.appendChild(consumer)
|
||||
this.currentTarget = target
|
||||
this.requestUpdate()
|
||||
}
|
||||
|
||||
private ensureConsumer(): LifecycleContractConsumer {
|
||||
const existing = this.querySelector<LifecycleContractConsumer>(
|
||||
'lifecycle-contract-consumer',
|
||||
)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
return document.createElement(
|
||||
'lifecycle-contract-consumer',
|
||||
) as LifecycleContractConsumer
|
||||
}
|
||||
|
||||
private getContainer(target: ContractTarget): HTMLElement {
|
||||
const selector =
|
||||
target === 'orphan'
|
||||
? '[data-contract-slot="orphan"]'
|
||||
: target === 'provider-a'
|
||||
? 'contract-provider-a'
|
||||
: 'contract-provider-b'
|
||||
|
||||
const container = this.querySelector(selector)
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error(
|
||||
`Lifecycle contract container not found for target "${target}".`,
|
||||
)
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<main>
|
||||
<h1>Lifecycle Contract Fixture</h1>
|
||||
<p>
|
||||
Exercises the same consumer across missing-provider and provider
|
||||
switching flows.
|
||||
</p>
|
||||
<div data-testid="contract-location">
|
||||
location: ${this.currentTarget}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Orphan Zone</h2>
|
||||
<div data-contract-slot="orphan"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Provider A</h2>
|
||||
<contract-provider-a></contract-provider-a>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Provider B</h2>
|
||||
<contract-provider-b></contract-provider-b>
|
||||
</section>
|
||||
</main>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('lifecycle-contract-root')) {
|
||||
customElements.define('lifecycle-contract-root', LifecycleContractRoot)
|
||||
}
|
||||
274
examples/lit/basic/src/main.ts
Normal file
274
examples/lit/basic/src/main.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { LitElement, html } from 'lit'
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
createMutationController,
|
||||
createQueryController,
|
||||
useIsFetching,
|
||||
useIsMutating,
|
||||
} from '@tanstack/lit-query'
|
||||
import {
|
||||
addTodoOnServer,
|
||||
failNextFetchRequest,
|
||||
failNextMutationRequest,
|
||||
fetchTodosFromServer,
|
||||
resetTodoApi,
|
||||
} from './todoApi'
|
||||
import type { Todo, TodosResponse } from './todoApi'
|
||||
|
||||
resetTodoApi()
|
||||
|
||||
const demoQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
class DemoQueryProvider extends QueryClientProvider {
|
||||
constructor() {
|
||||
super()
|
||||
this.client = demoQueryClient
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('demo-query-provider', DemoQueryProvider)
|
||||
|
||||
class TanstackLitQueryDemo extends LitElement {
|
||||
static properties = {
|
||||
nextTodoTitle: { state: true },
|
||||
cacheSeedCount: { state: true },
|
||||
}
|
||||
|
||||
private nextTodoTitle = 'Add mutation assertion'
|
||||
private cacheSeedCount = 0
|
||||
|
||||
private readonly todosQuery = createQueryController<TodosResponse, Error>(
|
||||
this,
|
||||
{
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodosFromServer,
|
||||
},
|
||||
)
|
||||
|
||||
private readonly createTodoMutation = createMutationController<
|
||||
Todo,
|
||||
Error,
|
||||
string
|
||||
>(this, {
|
||||
mutationKey: ['create-todo'],
|
||||
mutationFn: addTodoOnServer,
|
||||
onSuccess: (createdTodo) => {
|
||||
demoQueryClient.setQueryData<TodosResponse>(['todos'], (existing) => {
|
||||
if (!existing) {
|
||||
return {
|
||||
items: [createdTodo],
|
||||
requestCount: 0,
|
||||
source: 'cache',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: [...existing.items, createdTodo],
|
||||
requestCount: existing.requestCount,
|
||||
source: 'cache',
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
private readonly isFetching = useIsFetching(this, {
|
||||
queryKey: ['todos'],
|
||||
})
|
||||
|
||||
private readonly isMutating = useIsMutating(this, {
|
||||
mutationKey: ['create-todo'],
|
||||
})
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
|
||||
private onTitleInput(event: Event): void {
|
||||
const target = event.target as HTMLInputElement
|
||||
this.nextTodoTitle = target.value
|
||||
}
|
||||
|
||||
private addTodo(): void {
|
||||
const title = this.nextTodoTitle.trim()
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
|
||||
this.createTodoMutation.mutate(title)
|
||||
this.nextTodoTitle = ''
|
||||
}
|
||||
|
||||
private async invalidateTodos(): Promise<void> {
|
||||
await demoQueryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
}
|
||||
|
||||
private seedCacheOnlyTodo(): void {
|
||||
this.cacheSeedCount += 1
|
||||
|
||||
const seedTodo: Todo = {
|
||||
id: 10_000 + this.cacheSeedCount,
|
||||
title: `Seeded cache todo ${this.cacheSeedCount}`,
|
||||
}
|
||||
|
||||
demoQueryClient.setQueryData<TodosResponse>(['todos'], (existing) => {
|
||||
if (!existing) {
|
||||
return {
|
||||
items: [seedTodo],
|
||||
requestCount: 0,
|
||||
source: 'cache',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: [...existing.items, seedTodo],
|
||||
requestCount: existing.requestCount,
|
||||
source: 'cache',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private forceNextFetchFailure(): void {
|
||||
failNextFetchRequest()
|
||||
}
|
||||
|
||||
private forceNextMutationFailure(): void {
|
||||
failNextMutationRequest()
|
||||
}
|
||||
|
||||
private async resetDemoState(): Promise<void> {
|
||||
resetTodoApi()
|
||||
this.cacheSeedCount = 0
|
||||
this.nextTodoTitle = 'Add mutation assertion'
|
||||
|
||||
await demoQueryClient.resetQueries({ queryKey: ['todos'] })
|
||||
this.createTodoMutation.reset()
|
||||
}
|
||||
|
||||
render() {
|
||||
const query = this.todosQuery()
|
||||
const mutation = this.createTodoMutation()
|
||||
const todos = query.data?.items ?? []
|
||||
|
||||
return html`
|
||||
<main>
|
||||
<h1>TanStack Lit Query E2E Demo</h1>
|
||||
<p>Verifies integration between Lit, query-core, and this adapter.</p>
|
||||
|
||||
<section>
|
||||
<div data-testid="query-status">query: ${query.status}</div>
|
||||
<div data-testid="mutation-status">mutation: ${mutation.status}</div>
|
||||
<div data-testid="active-fetches">fetches: ${this.isFetching()}</div>
|
||||
<div data-testid="active-mutations">
|
||||
mutations: ${this.isMutating()}
|
||||
</div>
|
||||
<div data-testid="request-count">
|
||||
server-requests: ${query.data?.requestCount ?? 0}
|
||||
</div>
|
||||
<div data-testid="data-source">
|
||||
source: ${query.data?.source ?? 'none'}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<button
|
||||
data-testid="refetch"
|
||||
@click=${() => this.todosQuery.refetch()}
|
||||
>
|
||||
Refetch
|
||||
</button>
|
||||
<button
|
||||
data-testid="invalidate"
|
||||
@click=${() => this.invalidateTodos()}
|
||||
>
|
||||
Invalidate
|
||||
</button>
|
||||
<button
|
||||
data-testid="seed-cache"
|
||||
@click=${() => this.seedCacheOnlyTodo()}
|
||||
>
|
||||
Seed Cache
|
||||
</button>
|
||||
<button
|
||||
data-testid="reset-demo-state"
|
||||
@click=${() => this.resetDemoState()}
|
||||
>
|
||||
Reset Demo State
|
||||
</button>
|
||||
<button
|
||||
data-testid="fail-next-fetch"
|
||||
@click=${() => this.forceNextFetchFailure()}
|
||||
>
|
||||
Fail Next Fetch
|
||||
</button>
|
||||
<button
|
||||
data-testid="fail-next-mutation"
|
||||
@click=${() => this.forceNextMutationFailure()}
|
||||
>
|
||||
Fail Next Mutation
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<label for="newTodoInput">New todo</label>
|
||||
<input
|
||||
id="newTodoInput"
|
||||
data-testid="new-todo-input"
|
||||
.value=${this.nextTodoTitle}
|
||||
@input=${this.onTitleInput}
|
||||
/>
|
||||
<button data-testid="add-todo" @click=${() => this.addTodo()}>
|
||||
Add Todo (Mutation)
|
||||
</button>
|
||||
</section>
|
||||
|
||||
${query.isError
|
||||
? html`<div data-testid="query-error">${String(query.error)}</div>`
|
||||
: null}
|
||||
${mutation.isError
|
||||
? html`<div data-testid="mutation-error">
|
||||
${String(mutation.error)}
|
||||
</div>`
|
||||
: null}
|
||||
|
||||
<ul data-testid="todo-list">
|
||||
${todos.map(
|
||||
(todo) =>
|
||||
html`<li data-testid="todo-item">${todo.id}: ${todo.title}</li>`,
|
||||
)}
|
||||
</ul>
|
||||
</main>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tanstack-lit-query-demo', TanstackLitQueryDemo)
|
||||
|
||||
class DemoRoot extends LitElement {
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<demo-query-provider>
|
||||
<tanstack-lit-query-demo></tanstack-lit-query-demo>
|
||||
</demo-query-provider>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('demo-root', DemoRoot)
|
||||
139
examples/lit/basic/src/mutation.ts
Normal file
139
examples/lit/basic/src/mutation.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { LitElement, html } from 'lit'
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
createMutationController,
|
||||
createQueryController,
|
||||
} from '@tanstack/lit-query'
|
||||
import { addTodoOnServer, fetchTodosFromServer, resetTodoApi } from './todoApi'
|
||||
import type { Todo, TodosResponse } from './todoApi'
|
||||
|
||||
resetTodoApi()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
class MutationExampleProvider extends QueryClientProvider {
|
||||
constructor() {
|
||||
super()
|
||||
this.client = queryClient
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mutation-example-provider', MutationExampleProvider)
|
||||
|
||||
class MutationExample extends LitElement {
|
||||
static properties = {
|
||||
nextTitle: { state: true },
|
||||
}
|
||||
|
||||
private nextTitle = 'Created from mutation example'
|
||||
|
||||
private readonly todos = createQueryController<TodosResponse, Error>(this, {
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodosFromServer,
|
||||
})
|
||||
|
||||
private readonly addTodo = createMutationController<Todo, Error, string>(
|
||||
this,
|
||||
{
|
||||
mutationKey: ['create-todo'],
|
||||
mutationFn: addTodoOnServer,
|
||||
onSuccess: (created) => {
|
||||
queryClient.setQueryData<TodosResponse>(['todos'], (existing) => {
|
||||
if (!existing) {
|
||||
return {
|
||||
items: [created],
|
||||
requestCount: 0,
|
||||
source: 'cache',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: [...existing.items, created],
|
||||
requestCount: existing.requestCount,
|
||||
source: 'cache',
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
|
||||
private onInput(event: Event): void {
|
||||
const target = event.target as HTMLInputElement
|
||||
this.nextTitle = target.value
|
||||
}
|
||||
|
||||
private submit(): void {
|
||||
const title = this.nextTitle.trim()
|
||||
if (!title) return
|
||||
this.addTodo.mutate(title)
|
||||
this.nextTitle = ''
|
||||
}
|
||||
|
||||
render() {
|
||||
const query = this.todos()
|
||||
const mutation = this.addTodo()
|
||||
const items = query.data?.items ?? []
|
||||
|
||||
return html`
|
||||
<main>
|
||||
<h1>Mutation Example</h1>
|
||||
<p data-testid="mutation-query-status">
|
||||
Query: <strong>${query.status}</strong>
|
||||
</p>
|
||||
<p data-testid="mutation-mutation-status">
|
||||
Mutation: <strong>${mutation.status}</strong>
|
||||
</p>
|
||||
|
||||
<label for="title">Todo title</label>
|
||||
<input
|
||||
id="title"
|
||||
data-testid="mutation-input"
|
||||
.value=${this.nextTitle}
|
||||
@input=${this.onInput}
|
||||
/>
|
||||
<button data-testid="mutation-add" @click=${this.submit}>
|
||||
Add Todo
|
||||
</button>
|
||||
|
||||
<ul data-testid="mutation-todo-list">
|
||||
${items.map(
|
||||
(todo) =>
|
||||
html`<li data-testid="mutation-todo-item">${todo.title}</li>`,
|
||||
)}
|
||||
</ul>
|
||||
</main>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mutation-example', MutationExample)
|
||||
|
||||
class MutationExampleRoot extends LitElement {
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<mutation-example-provider>
|
||||
<mutation-example></mutation-example>
|
||||
</mutation-example-provider>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mutation-example-root', MutationExampleRoot)
|
||||
77
examples/lit/basic/src/todoApi.ts
Normal file
77
examples/lit/basic/src/todoApi.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type Todo = {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export type TodosResponse = {
|
||||
items: Todo[]
|
||||
requestCount: number
|
||||
source: 'server' | 'cache'
|
||||
}
|
||||
|
||||
let todos: Todo[] = [
|
||||
{ id: 1, title: 'Ship lit-query alpha' },
|
||||
{ id: 2, title: 'Write integration checks' },
|
||||
]
|
||||
|
||||
let requestCount = 0
|
||||
let nextTodoId = 3
|
||||
let failNextFetch = false
|
||||
let failNextMutation = false
|
||||
|
||||
const delay = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
|
||||
export async function fetchTodosFromServer(): Promise<TodosResponse> {
|
||||
await delay(90)
|
||||
if (failNextFetch) {
|
||||
failNextFetch = false
|
||||
throw new Error('Forced fetch failure (test)')
|
||||
}
|
||||
requestCount += 1
|
||||
|
||||
return {
|
||||
items: todos.map((todo) => ({ ...todo })),
|
||||
requestCount,
|
||||
source: 'server',
|
||||
}
|
||||
}
|
||||
|
||||
export async function addTodoOnServer(title: string): Promise<Todo> {
|
||||
await delay(70)
|
||||
if (failNextMutation) {
|
||||
failNextMutation = false
|
||||
throw new Error('Forced mutation failure (test)')
|
||||
}
|
||||
|
||||
const nextTodo: Todo = {
|
||||
id: nextTodoId,
|
||||
title,
|
||||
}
|
||||
|
||||
nextTodoId += 1
|
||||
todos = [...todos, nextTodo]
|
||||
|
||||
return { ...nextTodo }
|
||||
}
|
||||
|
||||
export function resetTodoApi(): void {
|
||||
todos = [
|
||||
{ id: 1, title: 'Ship lit-query alpha' },
|
||||
{ id: 2, title: 'Write integration checks' },
|
||||
]
|
||||
requestCount = 0
|
||||
nextTodoId = 3
|
||||
failNextFetch = false
|
||||
failNextMutation = false
|
||||
}
|
||||
|
||||
export function failNextFetchRequest(): void {
|
||||
failNextFetch = true
|
||||
}
|
||||
|
||||
export function failNextMutationRequest(): void {
|
||||
failNextMutation = true
|
||||
}
|
||||
14
examples/lit/basic/tsconfig.json
Normal file
14
examples/lit/basic/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "vite.config.ts"]
|
||||
}
|
||||
15
examples/lit/basic/vite.config.ts
Normal file
15
examples/lit/basic/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { DEMO_PORT } from './config/port.js'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: DEMO_PORT,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
host: '127.0.0.1',
|
||||
port: DEMO_PORT,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
8
examples/lit/pagination/CHANGELOG.md
Normal file
8
examples/lit/pagination/CHANGELOG.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# @tanstack/query-example-lit-pagination
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4082894`](https://github.com/TanStack/query/commit/4082894509f31376ebeb8514cc3e167bbbfc7c46)]:
|
||||
- @tanstack/lit-query@0.2.0
|
||||
5
examples/lit/pagination/README.md
Normal file
5
examples/lit/pagination/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Example
|
||||
|
||||
To run this example from the repo root:
|
||||
|
||||
- `pnpm --dir examples/lit/pagination run dev`
|
||||
2
examples/lit/pagination/config/ports.d.ts
vendored
Normal file
2
examples/lit/pagination/config/ports.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export const DEMO_PORT: number
|
||||
export const API_PORT: number
|
||||
26
examples/lit/pagination/config/ports.js
Normal file
26
examples/lit/pagination/config/ports.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const DEFAULT_DEMO_PORT = 4183
|
||||
const DEFAULT_API_PORT = 4184
|
||||
|
||||
function readPortFromEnv(name, fallback) {
|
||||
const rawValue = process.env[name]
|
||||
if (!rawValue) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rawValue, 10)
|
||||
const valid = Number.isInteger(parsed) && parsed >= 1 && parsed <= 65_535
|
||||
|
||||
if (!valid) {
|
||||
throw new Error(
|
||||
`Invalid ${name}="${rawValue}". Expected integer in [1, 65535].`,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export const DEMO_PORT = readPortFromEnv(
|
||||
'PAGINATION_DEMO_PORT',
|
||||
DEFAULT_DEMO_PORT,
|
||||
)
|
||||
export const API_PORT = readPortFromEnv('PAGINATION_API_PORT', DEFAULT_API_PORT)
|
||||
12
examples/lit/pagination/index.html
Normal file
12
examples/lit/pagination/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TanStack Lit Query Pagination Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<pagination-demo-root></pagination-demo-root>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
examples/lit/pagination/package.json
Normal file
19
examples/lit/pagination/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@tanstack/query-example-lit-pagination",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node ./scripts/dev.mjs",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/lit-query": "^0.2.20",
|
||||
"@tanstack/query-core": "^5.102.8",
|
||||
"lit": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.8.3",
|
||||
"vite": "^6.4.1"
|
||||
}
|
||||
}
|
||||
80
examples/lit/pagination/scripts/dev.mjs
Normal file
80
examples/lit/pagination/scripts/dev.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { API_PORT } from '../config/ports.js'
|
||||
|
||||
const viteCommand = process.platform === 'win32' ? 'vite.cmd' : 'vite'
|
||||
const cwd = new URL('..', import.meta.url)
|
||||
|
||||
function forwardOutput(prefix, stream, output) {
|
||||
stream.on('data', (chunk) => {
|
||||
output.write(`${prefix}${chunk}`)
|
||||
})
|
||||
}
|
||||
|
||||
function start(name, command, args, extraEnv = {}) {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
...extraEnv,
|
||||
},
|
||||
})
|
||||
|
||||
forwardOutput(`[${name}] `, child.stdout, process.stdout)
|
||||
forwardOutput(`[${name}] `, child.stderr, process.stderr)
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
async function stop(child) {
|
||||
if (!child || child.exitCode !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
child.kill('SIGTERM')
|
||||
await Promise.race([
|
||||
once(child, 'exit'),
|
||||
new Promise((resolve) => setTimeout(resolve, 2000)),
|
||||
])
|
||||
|
||||
if (child.exitCode === null) {
|
||||
child.kill('SIGKILL')
|
||||
await Promise.race([
|
||||
once(child, 'exit'),
|
||||
new Promise((resolve) => setTimeout(resolve, 2000)),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const api = start('api', process.execPath, ['./server/index.mjs'])
|
||||
const web = start('web', viteCommand, [], {
|
||||
VITE_PAGINATION_API_PORT: String(API_PORT),
|
||||
})
|
||||
|
||||
const shutdown = async () => {
|
||||
await Promise.all([stop(web), stop(api)])
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
|
||||
const [winner] = await Promise.race([
|
||||
once(api, 'exit').then(([code]) => ({ name: 'api', code })),
|
||||
once(web, 'exit').then(([code]) => ({ name: 'web', code })),
|
||||
])
|
||||
|
||||
await shutdown()
|
||||
|
||||
if (winner.code !== 0 && winner.code !== null) {
|
||||
process.exitCode = winner.code
|
||||
} else {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
340
examples/lit/pagination/server/index.mjs
Normal file
340
examples/lit/pagination/server/index.mjs
Normal file
@@ -0,0 +1,340 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { API_PORT } from '../config/ports.js'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const TOTAL_PROJECTS = 50
|
||||
const JSON_CONTENT_TYPE = 'application/json'
|
||||
|
||||
function createSeedProjects() {
|
||||
return Array.from({ length: TOTAL_PROJECTS }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `Project ${index + 1}`,
|
||||
owner: `Team ${(index % 5) + 1}`,
|
||||
isFavorite: false,
|
||||
}))
|
||||
}
|
||||
|
||||
let projects = createSeedProjects()
|
||||
let nextProjectId = projects.length + 1
|
||||
let totalRequestCount = 0
|
||||
let totalMutationCount = 0
|
||||
let failNextMutation = false
|
||||
const perPageRequestCount = new Map()
|
||||
|
||||
function writeJson(res, status, body) {
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': 'GET,POST,PATCH,OPTIONS',
|
||||
'access-control-allow-headers': 'content-type',
|
||||
'content-length': Buffer.byteLength(payload),
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
function parsePositiveInt(rawValue, fallback) {
|
||||
if (rawValue == null || rawValue === '') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rawValue, 10)
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseNonNegativeInt(rawValue, fallback) {
|
||||
if (rawValue == null || rawValue === '') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rawValue, 10)
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
projects = createSeedProjects()
|
||||
nextProjectId = projects.length + 1
|
||||
totalRequestCount = 0
|
||||
totalMutationCount = 0
|
||||
failNextMutation = false
|
||||
perPageRequestCount.clear()
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
if (!ms || ms <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
const contentType = req.headers['content-type']
|
||||
if (!contentType || !contentType.startsWith(JSON_CONTENT_TYPE)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 415,
|
||||
error: 'Expected application/json request body',
|
||||
}
|
||||
}
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString('utf8')
|
||||
if (!raw) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: 'Request body is required',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: JSON.parse(raw),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: 'Request body must be valid JSON',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildProjectsPage(page) {
|
||||
const totalPages = Math.max(1, Math.ceil(projects.length / PAGE_SIZE))
|
||||
const boundedPage = Math.min(page, totalPages)
|
||||
const startIndex = (boundedPage - 1) * PAGE_SIZE
|
||||
const endIndex = startIndex + PAGE_SIZE
|
||||
const items = projects.slice(startIndex, endIndex)
|
||||
|
||||
totalRequestCount += 1
|
||||
const pageRequests = (perPageRequestCount.get(boundedPage) ?? 0) + 1
|
||||
perPageRequestCount.set(boundedPage, pageRequests)
|
||||
|
||||
return {
|
||||
page: boundedPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
totalPages,
|
||||
totalProjects: projects.length,
|
||||
hasMore: boundedPage < totalPages,
|
||||
projects: items,
|
||||
requestMeta: {
|
||||
totalRequestCount,
|
||||
pageRequestCount: pageRequests,
|
||||
totalMutationCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function maybeFailMutation(res) {
|
||||
if (!failNextMutation) {
|
||||
return false
|
||||
}
|
||||
|
||||
failNextMutation = false
|
||||
writeJson(res, 500, { error: 'Forced mutation failure (test)' })
|
||||
return true
|
||||
}
|
||||
|
||||
function validateProjectName(name) {
|
||||
if (typeof name !== 'string') {
|
||||
return 'Project name must be a string'
|
||||
}
|
||||
|
||||
const trimmed = name.trim()
|
||||
if (trimmed.length < 3) {
|
||||
return 'Project name must be at least 3 characters'
|
||||
}
|
||||
|
||||
if (trimmed.length > 60) {
|
||||
return 'Project name must be 60 characters or fewer'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function validateOwner(owner) {
|
||||
if (typeof owner !== 'string') {
|
||||
return 'Owner must be a string'
|
||||
}
|
||||
|
||||
const trimmed = owner.trim()
|
||||
if (!trimmed) {
|
||||
return 'Owner is required'
|
||||
}
|
||||
|
||||
if (trimmed.length > 40) {
|
||||
return 'Owner must be 40 characters or fewer'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': 'GET,POST,PATCH,OPTIONS',
|
||||
'access-control-allow-headers': 'content-type',
|
||||
'cache-control': 'no-store',
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (!req.url) {
|
||||
writeJson(res, 400, { error: 'Missing URL' })
|
||||
return
|
||||
}
|
||||
|
||||
const requestUrl = new URL(req.url, `http://127.0.0.1:${API_PORT}`)
|
||||
|
||||
if (requestUrl.pathname === '/api/projects' && req.method === 'GET') {
|
||||
const page = parsePositiveInt(requestUrl.searchParams.get('page'), 1)
|
||||
if (!page) {
|
||||
writeJson(res, 400, { error: 'Invalid page parameter' })
|
||||
return
|
||||
}
|
||||
|
||||
const delayMs = parseNonNegativeInt(requestUrl.searchParams.get('delay'), 0)
|
||||
if (delayMs === undefined) {
|
||||
writeJson(res, 400, { error: 'Invalid delay parameter' })
|
||||
return
|
||||
}
|
||||
|
||||
await sleep(delayMs)
|
||||
|
||||
if (requestUrl.searchParams.get('error') === 'true') {
|
||||
writeJson(res, 500, {
|
||||
error: 'Forced server error (test)',
|
||||
page,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJson(res, 200, buildProjectsPage(page))
|
||||
return
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === '/api/projects' && req.method === 'POST') {
|
||||
if (maybeFailMutation(res)) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await readJsonBody(req)
|
||||
if (!payload.ok) {
|
||||
writeJson(res, payload.status, { error: payload.error })
|
||||
return
|
||||
}
|
||||
|
||||
const nameError = validateProjectName(payload.value?.name)
|
||||
if (nameError) {
|
||||
writeJson(res, 422, { error: nameError })
|
||||
return
|
||||
}
|
||||
|
||||
const ownerError = validateOwner(payload.value?.owner)
|
||||
if (ownerError) {
|
||||
writeJson(res, 422, { error: ownerError })
|
||||
return
|
||||
}
|
||||
|
||||
const project = {
|
||||
id: nextProjectId,
|
||||
name: payload.value.name.trim(),
|
||||
owner: payload.value.owner.trim(),
|
||||
isFavorite: false,
|
||||
}
|
||||
|
||||
nextProjectId += 1
|
||||
totalMutationCount += 1
|
||||
projects = [project, ...projects]
|
||||
|
||||
writeJson(res, 201, {
|
||||
project,
|
||||
mutationCount: totalMutationCount,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const patchMatch = requestUrl.pathname.match(/^\/api\/projects\/(\d+)$/)
|
||||
if (patchMatch && req.method === 'PATCH') {
|
||||
if (maybeFailMutation(res)) {
|
||||
return
|
||||
}
|
||||
|
||||
const projectId = Number.parseInt(patchMatch[1], 10)
|
||||
const projectIndex = projects.findIndex(
|
||||
(project) => project.id === projectId,
|
||||
)
|
||||
if (projectIndex === -1) {
|
||||
writeJson(res, 404, { error: `Project ${projectId} was not found` })
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await readJsonBody(req)
|
||||
if (!payload.ok) {
|
||||
writeJson(res, payload.status, { error: payload.error })
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof payload.value?.isFavorite !== 'boolean') {
|
||||
writeJson(res, 422, { error: 'isFavorite must be a boolean' })
|
||||
return
|
||||
}
|
||||
|
||||
const nextProject = {
|
||||
...projects[projectIndex],
|
||||
isFavorite: payload.value.isFavorite,
|
||||
}
|
||||
|
||||
totalMutationCount += 1
|
||||
projects = projects.map((project, index) =>
|
||||
index === projectIndex ? nextProject : project,
|
||||
)
|
||||
|
||||
writeJson(res, 200, {
|
||||
project: nextProject,
|
||||
mutationCount: totalMutationCount,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
requestUrl.pathname === '/api/testing/fail-next-mutation' &&
|
||||
req.method === 'POST'
|
||||
) {
|
||||
failNextMutation = true
|
||||
writeJson(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === '/api/reset' && req.method === 'POST') {
|
||||
resetState()
|
||||
writeJson(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
writeJson(res, 404, { error: 'Not found' })
|
||||
})
|
||||
|
||||
server.listen(API_PORT, '127.0.0.1', () => {
|
||||
console.log(`[api] listening on http://127.0.0.1:${API_PORT}`)
|
||||
})
|
||||
166
examples/lit/pagination/src/api.ts
Normal file
166
examples/lit/pagination/src/api.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
export type Project = {
|
||||
id: number
|
||||
name: string
|
||||
owner: string
|
||||
isFavorite: boolean
|
||||
}
|
||||
|
||||
export type ProjectsPageResponse = {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
totalProjects: number
|
||||
hasMore: boolean
|
||||
projects: Project[]
|
||||
requestMeta: {
|
||||
totalRequestCount: number
|
||||
pageRequestCount: number
|
||||
totalMutationCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateProjectInput = {
|
||||
name: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export type ToggleProjectFavoriteInput = {
|
||||
id: number
|
||||
isFavorite: boolean
|
||||
}
|
||||
|
||||
export type ProjectMutationResponse = {
|
||||
project: Project
|
||||
mutationCount: number
|
||||
}
|
||||
|
||||
export type ProjectsQueryKey = readonly ['projects', number, number, boolean]
|
||||
|
||||
const DEFAULT_API_PORT = 4184
|
||||
const configuredApiPort = Number.parseInt(
|
||||
import.meta.env.VITE_PAGINATION_API_PORT ?? String(DEFAULT_API_PORT),
|
||||
10,
|
||||
)
|
||||
const API_PORT = Number.isInteger(configuredApiPort)
|
||||
? configuredApiPort
|
||||
: DEFAULT_API_PORT
|
||||
const API_BASE_URL = `http://127.0.0.1:${API_PORT}`
|
||||
|
||||
function buildProjectsUrl(
|
||||
page: number,
|
||||
delayMs: number,
|
||||
forceError: boolean,
|
||||
): URL {
|
||||
const url = new URL('/api/projects', API_BASE_URL)
|
||||
url.searchParams.set('page', String(page))
|
||||
|
||||
if (delayMs > 0) {
|
||||
url.searchParams.set('delay', String(delayMs))
|
||||
}
|
||||
|
||||
if (forceError) {
|
||||
url.searchParams.set('error', 'true')
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
async function readJsonOrThrow<T>(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
): Promise<T> {
|
||||
if (response.ok) {
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
error?: string
|
||||
} | null
|
||||
|
||||
throw new Error(
|
||||
payload && typeof payload === 'object' && 'error' in payload
|
||||
? String(payload.error ?? fallbackMessage)
|
||||
: fallbackMessage,
|
||||
)
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
url: URL,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, init)
|
||||
return readJsonOrThrow<T>(response, fallbackMessage)
|
||||
}
|
||||
|
||||
export function projectsQueryKey(
|
||||
page: number,
|
||||
delayMs: number,
|
||||
forceError: boolean,
|
||||
): ProjectsQueryKey {
|
||||
return ['projects', page, delayMs, forceError] as const
|
||||
}
|
||||
|
||||
export async function fetchProjectsPage(
|
||||
page: number,
|
||||
delayMs: number,
|
||||
forceError: boolean,
|
||||
): Promise<ProjectsPageResponse> {
|
||||
const response = await fetch(buildProjectsUrl(page, delayMs, forceError))
|
||||
return readJsonOrThrow<ProjectsPageResponse>(
|
||||
response,
|
||||
`Failed to fetch projects page ${page}`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function createProjectOnServer(
|
||||
input: CreateProjectInput,
|
||||
): Promise<ProjectMutationResponse> {
|
||||
return requestJson<ProjectMutationResponse>(
|
||||
new URL('/api/projects', API_BASE_URL),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
'Failed to create project',
|
||||
)
|
||||
}
|
||||
|
||||
export async function toggleProjectFavoriteOnServer(
|
||||
input: ToggleProjectFavoriteInput,
|
||||
): Promise<ProjectMutationResponse> {
|
||||
return requestJson<ProjectMutationResponse>(
|
||||
new URL(`/api/projects/${input.id}`, API_BASE_URL),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ isFavorite: input.isFavorite }),
|
||||
},
|
||||
`Failed to update project ${input.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function armNextProjectMutationFailureOnServer(): Promise<void> {
|
||||
await requestJson<{ ok: true }>(
|
||||
new URL('/api/testing/fail-next-mutation', API_BASE_URL),
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'Failed to arm next mutation failure',
|
||||
)
|
||||
}
|
||||
|
||||
export async function resetProjectsApiState(): Promise<void> {
|
||||
await requestJson<{ ok: true }>(
|
||||
new URL('/api/reset', API_BASE_URL),
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'Failed to reset API state',
|
||||
)
|
||||
}
|
||||
588
examples/lit/pagination/src/main.ts
Normal file
588
examples/lit/pagination/src/main.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
import { LitElement, html } from 'lit'
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
createMutationController,
|
||||
createQueryController,
|
||||
keepPreviousData,
|
||||
noop,
|
||||
} 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
|
||||
.query({
|
||||
queryKey: projectsQueryKey(
|
||||
targetPage,
|
||||
this.delayMs,
|
||||
this.forceErrorMode,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchProjectsPage(targetPage, this.delayMs, this.forceErrorMode),
|
||||
})
|
||||
.catch(noop)
|
||||
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)
|
||||
9
examples/lit/pagination/src/vite-env.d.ts
vendored
Normal file
9
examples/lit/pagination/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_PAGINATION_API_PORT?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
14
examples/lit/pagination/tsconfig.json
Normal file
14
examples/lit/pagination/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "vite.config.ts"]
|
||||
}
|
||||
15
examples/lit/pagination/vite.config.ts
Normal file
15
examples/lit/pagination/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { DEMO_PORT } from './config/ports.js'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: DEMO_PORT,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
host: '127.0.0.1',
|
||||
port: DEMO_PORT,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
8
examples/lit/ssr/CHANGELOG.md
Normal file
8
examples/lit/ssr/CHANGELOG.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# @tanstack/query-example-lit-ssr
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4082894`](https://github.com/TanStack/query/commit/4082894509f31376ebeb8514cc3e167bbbfc7c46)]:
|
||||
- @tanstack/lit-query@0.2.0
|
||||
5
examples/lit/ssr/README.md
Normal file
5
examples/lit/ssr/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Example
|
||||
|
||||
To run this example from the repo root:
|
||||
|
||||
- `pnpm --dir examples/lit/ssr run dev`
|
||||
5
examples/lit/ssr/config/ports.d.ts
vendored
Normal file
5
examples/lit/ssr/config/ports.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export const SSR_HOST: string
|
||||
export const SSR_PORT: number
|
||||
export const SSR_CONNECT_HOST: string
|
||||
export const SSR_BASE_URL: string
|
||||
export const SSR_PUBLIC_ORIGIN: string
|
||||
63
examples/lit/ssr/config/ports.js
Normal file
63
examples/lit/ssr/config/ports.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const DEFAULT_SSR_HOST = '127.0.0.1'
|
||||
const DEFAULT_SSR_PORT = 4174
|
||||
|
||||
function normalizeUrlHost(host) {
|
||||
if (host.includes(':') && !host.startsWith('[') && !host.endsWith(']')) {
|
||||
return `[${host}]`
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
function resolvePort(name, fallback) {
|
||||
const value = process.env[name]
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsedPort = Number.parseInt(value, 10)
|
||||
const isValidPort =
|
||||
Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65_535
|
||||
|
||||
if (!isValidPort) {
|
||||
throw new Error(
|
||||
`Invalid ${name} "${value}". Expected an integer between 1 and 65535.`,
|
||||
)
|
||||
}
|
||||
|
||||
return parsedPort
|
||||
}
|
||||
|
||||
export const SSR_PORT = resolvePort('SSR_PORT', DEFAULT_SSR_PORT)
|
||||
export const SSR_HOST = process.env.SSR_HOST ?? DEFAULT_SSR_HOST
|
||||
|
||||
function resolveConnectHost(host) {
|
||||
if (host === '0.0.0.0') {
|
||||
return '127.0.0.1'
|
||||
}
|
||||
|
||||
if (host === '::') {
|
||||
return '[::1]'
|
||||
}
|
||||
|
||||
return normalizeUrlHost(host)
|
||||
}
|
||||
|
||||
function resolvePublicOrigin(host, port) {
|
||||
const explicitOrigin = process.env.SSR_PUBLIC_ORIGIN
|
||||
if (explicitOrigin) {
|
||||
const url = new URL(explicitOrigin)
|
||||
return url.origin
|
||||
}
|
||||
|
||||
const explicitPublicHost = process.env.SSR_PUBLIC_HOST
|
||||
const publicHost = explicitPublicHost
|
||||
? normalizeUrlHost(explicitPublicHost)
|
||||
: resolveConnectHost(host)
|
||||
|
||||
return `http://${publicHost}:${port}`
|
||||
}
|
||||
|
||||
export const SSR_CONNECT_HOST = resolveConnectHost(SSR_HOST)
|
||||
export const SSR_BASE_URL = `http://${SSR_CONNECT_HOST}:${SSR_PORT}`
|
||||
export const SSR_PUBLIC_ORIGIN = resolvePublicOrigin(SSR_HOST, SSR_PORT)
|
||||
15
examples/lit/ssr/index.html
Normal file
15
examples/lit/ssr/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lit Query SSR Example</title>
|
||||
</head>
|
||||
<body>
|
||||
__SSR_APP_HTML__
|
||||
<script id="__QUERY_STATE__" type="application/json">
|
||||
__QUERY_STATE_JSON__
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
examples/lit/ssr/package.json
Normal file
21
examples/lit/ssr/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@tanstack/query-example-lit-ssr",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node ./scripts/dev.mjs",
|
||||
"build": "tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr": "^3.3.0",
|
||||
"@tanstack/lit-query": "^0.2.20",
|
||||
"@tanstack/query-core": "^5.102.8",
|
||||
"lit": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lit-labs/ssr-client": "^1.1.7",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "^6.4.1"
|
||||
}
|
||||
}
|
||||
51
examples/lit/ssr/scripts/dev.mjs
Normal file
51
examples/lit/ssr/scripts/dev.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
const tsxCommand = process.platform === 'win32' ? 'tsx.cmd' : 'tsx'
|
||||
const cwd = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
function runBuild() {
|
||||
const result = spawnSync(npmCommand, ['run', 'build'], {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
runBuild()
|
||||
|
||||
const server = spawn(tsxCommand, ['./server/index.mjs'], {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
const stopServer = (signal) => {
|
||||
if (server.exitCode === null) {
|
||||
server.kill(signal)
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => stopServer('SIGINT'))
|
||||
process.on('SIGTERM', () => stopServer('SIGTERM'))
|
||||
|
||||
const outcome = await Promise.race([
|
||||
once(server, 'error').then(([error]) => {
|
||||
throw error
|
||||
}),
|
||||
once(server, 'exit').then(([code]) => ({ code })),
|
||||
])
|
||||
|
||||
process.exitCode = outcome.code ?? 0
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
308
examples/lit/ssr/server/index.mjs
Normal file
308
examples/lit/ssr/server/index.mjs
Normal file
@@ -0,0 +1,308 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, extname, resolve, sep } from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { render } from '@lit-labs/ssr'
|
||||
import { collectResult } from '@lit-labs/ssr/lib/render-result.js'
|
||||
import { html } from 'lit'
|
||||
import { QueryClient, dehydrate } from '@tanstack/lit-query'
|
||||
import {
|
||||
SSR_BASE_URL,
|
||||
SSR_HOST,
|
||||
SSR_PORT,
|
||||
SSR_PUBLIC_ORIGIN,
|
||||
} from '../config/ports.js'
|
||||
import {
|
||||
DATA_QUERY_KEY,
|
||||
DEFAULT_MESSAGE,
|
||||
QUERY_STALE_TIME,
|
||||
createDataQueryOptions,
|
||||
} from '../src/api.js'
|
||||
import {
|
||||
getSsrQueryControllerCreationCount,
|
||||
resetSsrQueryControllerCreationCount,
|
||||
} from '../src/app.ts'
|
||||
|
||||
const serverDir = dirname(fileURLToPath(import.meta.url))
|
||||
const distDir = resolve(serverDir, '../dist')
|
||||
const templatePath = resolve(distDir, 'index.html')
|
||||
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
}
|
||||
|
||||
let requestCount = 0
|
||||
let failNextDataRequest = false
|
||||
let nextDataDelayMs = 0
|
||||
|
||||
const apiCorsHeaders = {
|
||||
'access-control-allow-headers': 'content-type',
|
||||
'access-control-allow-methods': 'GET,POST,OPTIONS',
|
||||
'access-control-allow-origin': '*',
|
||||
}
|
||||
|
||||
function createBrowserQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: QUERY_STALE_TIME,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function nextDataResponse() {
|
||||
requestCount += 1
|
||||
return {
|
||||
message: DEFAULT_MESSAGE,
|
||||
requestCount,
|
||||
servedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function resetApiState() {
|
||||
requestCount = 0
|
||||
failNextDataRequest = false
|
||||
nextDataDelayMs = 0
|
||||
}
|
||||
|
||||
function parseNonNegativeDelayMs(value) {
|
||||
const parsedDelayMs = Number.parseInt(value ?? '', 10)
|
||||
if (!Number.isInteger(parsedDelayMs) || parsedDelayMs < 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsedDelayMs
|
||||
}
|
||||
|
||||
async function consumeNextDataDelay() {
|
||||
const delayMs = nextDataDelayMs
|
||||
nextDataDelayMs = 0
|
||||
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
function consumeFailNextDataRequest() {
|
||||
if (!failNextDataRequest) {
|
||||
return false
|
||||
}
|
||||
|
||||
failNextDataRequest = false
|
||||
return true
|
||||
}
|
||||
|
||||
function writeBuffer(res, statusCode, contentType, payload, extraHeaders = {}) {
|
||||
res.writeHead(statusCode, {
|
||||
'cache-control': 'no-store',
|
||||
'content-length': payload.byteLength,
|
||||
'content-type': contentType,
|
||||
...extraHeaders,
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
function writeHtml(res, statusCode, body, extraHeaders = {}) {
|
||||
const payload = Buffer.from(body)
|
||||
writeBuffer(
|
||||
res,
|
||||
statusCode,
|
||||
'text/html; charset=utf-8',
|
||||
payload,
|
||||
extraHeaders,
|
||||
)
|
||||
}
|
||||
|
||||
function writeJson(res, statusCode, body) {
|
||||
const payload = Buffer.from(JSON.stringify(body))
|
||||
writeBuffer(
|
||||
res,
|
||||
statusCode,
|
||||
'application/json; charset=utf-8',
|
||||
payload,
|
||||
apiCorsHeaders,
|
||||
)
|
||||
}
|
||||
|
||||
function serializeJsonForHtml(value) {
|
||||
return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) => {
|
||||
switch (character) {
|
||||
case '<':
|
||||
return '\\u003c'
|
||||
case '>':
|
||||
return '\\u003e'
|
||||
case '&':
|
||||
return '\\u0026'
|
||||
case '\u2028':
|
||||
return '\\u2028'
|
||||
case '\u2029':
|
||||
return '\\u2029'
|
||||
default:
|
||||
return character
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function readTemplate() {
|
||||
try {
|
||||
return await readFile(templatePath, 'utf8')
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
throw new Error(
|
||||
'Missing built client assets. Run "pnpm --dir examples/lit/ssr run build" from the repo root first.',
|
||||
)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function serveAsset(pathname, res) {
|
||||
if (!pathname.startsWith('/assets/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const assetPath = resolve(distDir, `.${pathname}`)
|
||||
const distRootPrefix = `${distDir}${sep}`
|
||||
if (!assetPath.startsWith(distRootPrefix)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await readFile(assetPath)
|
||||
const contentType =
|
||||
contentTypes[extname(assetPath)] ?? 'application/octet-stream'
|
||||
writeBuffer(res, 200, contentType, file)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage(res) {
|
||||
const queryClient = createBrowserQueryClient()
|
||||
resetSsrQueryControllerCreationCount(queryClient)
|
||||
await queryClient.prefetchQuery(createDataQueryOptions(SSR_PUBLIC_ORIGIN))
|
||||
|
||||
const prefetchedQueryState = queryClient.getQueryState(DATA_QUERY_KEY)
|
||||
if (prefetchedQueryState?.status !== 'success') {
|
||||
throw new Error(
|
||||
'SSR prefetch did not complete successfully. Refusing to render loading HTML.',
|
||||
)
|
||||
}
|
||||
|
||||
const appHtml = await collectResult(
|
||||
render(
|
||||
html`<ssr-app
|
||||
api-base-url=${SSR_PUBLIC_ORIGIN}
|
||||
.queryClient=${queryClient}
|
||||
></ssr-app>`,
|
||||
),
|
||||
)
|
||||
|
||||
const dehydratedState = dehydrate(queryClient)
|
||||
const controllerCreationCount =
|
||||
getSsrQueryControllerCreationCount(queryClient)
|
||||
if (controllerCreationCount < 1) {
|
||||
throw new Error('SSR render did not exercise createQueryController.')
|
||||
}
|
||||
|
||||
const template = await readTemplate()
|
||||
const htmlDocument = template
|
||||
.replace('__SSR_APP_HTML__', appHtml)
|
||||
.replace('__QUERY_STATE_JSON__', serializeJsonForHtml(dehydratedState))
|
||||
|
||||
writeHtml(res, 200, htmlDocument, {
|
||||
'x-ssr-query-controller-created': String(controllerCreationCount),
|
||||
})
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const requestUrl = new URL(req.url ?? '/', SSR_BASE_URL)
|
||||
const method = req.method ?? 'GET'
|
||||
|
||||
if (method === 'OPTIONS' && requestUrl.pathname.startsWith('/api/')) {
|
||||
res.writeHead(204, {
|
||||
'cache-control': 'no-store',
|
||||
...apiCorsHeaders,
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'GET' && requestUrl.pathname === '/api/data') {
|
||||
await consumeNextDataDelay()
|
||||
|
||||
if (consumeFailNextDataRequest()) {
|
||||
writeJson(res, 500, { error: 'Forced data failure (test)' })
|
||||
return
|
||||
}
|
||||
|
||||
writeJson(res, 200, nextDataResponse())
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'GET' && requestUrl.pathname === '/api/request-count') {
|
||||
writeJson(res, 200, { count: requestCount })
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'POST' && requestUrl.pathname === '/api/reset') {
|
||||
resetApiState()
|
||||
writeJson(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'POST' && requestUrl.pathname === '/api/test/fail-next-data') {
|
||||
failNextDataRequest = true
|
||||
writeJson(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'POST' &&
|
||||
requestUrl.pathname === '/api/test/delay-next-data'
|
||||
) {
|
||||
const delayMs = parseNonNegativeDelayMs(requestUrl.searchParams.get('ms'))
|
||||
if (delayMs === undefined) {
|
||||
writeJson(res, 400, { error: 'Invalid ms query parameter.' })
|
||||
return
|
||||
}
|
||||
|
||||
nextDataDelayMs = delayMs
|
||||
writeJson(res, 200, { ok: true, delayMs })
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'GET' && requestUrl.pathname === '/') {
|
||||
try {
|
||||
await renderPage(res)
|
||||
} catch (error) {
|
||||
console.error('[ssr] render failed:', error)
|
||||
writeHtml(
|
||||
res,
|
||||
500,
|
||||
'<!doctype html><html><body>SSR render failed.</body></html>',
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'GET' && (await serveAsset(requestUrl.pathname, res))) {
|
||||
return
|
||||
}
|
||||
|
||||
writeJson(res, 404, { error: 'Not found' })
|
||||
})
|
||||
|
||||
server.listen(SSR_PORT, SSR_HOST, () => {
|
||||
console.log(
|
||||
`[ssr] listening on ${SSR_BASE_URL} (public origin ${SSR_PUBLIC_ORIGIN})`,
|
||||
)
|
||||
})
|
||||
69
examples/lit/ssr/src/api.ts
Normal file
69
examples/lit/ssr/src/api.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { CreateQueryOptions } from '@tanstack/lit-query'
|
||||
|
||||
export const DATA_QUERY_KEY = ['ssr-example-data'] as const
|
||||
export const DEFAULT_MESSAGE = 'Hello from SSR!'
|
||||
export const QUERY_STALE_TIME = 30_000
|
||||
|
||||
export type DataResponse = {
|
||||
message: string
|
||||
requestCount: number
|
||||
servedAt: string
|
||||
}
|
||||
|
||||
function resolveApiUrl(pathname: string, apiBaseUrl: string): string {
|
||||
if (!apiBaseUrl) {
|
||||
return pathname
|
||||
}
|
||||
|
||||
return new URL(pathname, apiBaseUrl).toString()
|
||||
}
|
||||
|
||||
async function readJson<TResponse>(response: Response): Promise<TResponse> {
|
||||
let payload: TResponse | { error?: string } | null = null
|
||||
|
||||
try {
|
||||
payload = (await response.json()) as TResponse | { error?: string }
|
||||
} catch {
|
||||
if (response.ok) {
|
||||
throw new Error(
|
||||
`Failed to parse JSON response with status ${response.status}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorDetail =
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
'error' in payload &&
|
||||
typeof payload.error === 'string'
|
||||
? `: ${payload.error}`
|
||||
: ''
|
||||
|
||||
throw new Error(
|
||||
`Request failed with status ${response.status}${errorDetail}`,
|
||||
)
|
||||
}
|
||||
|
||||
return payload as TResponse
|
||||
}
|
||||
|
||||
export function createDataQueryOptions(apiBaseUrl = '') {
|
||||
return {
|
||||
queryKey: DATA_QUERY_KEY,
|
||||
queryFn: async ({ signal }) => {
|
||||
const response = await fetch(resolveApiUrl('/api/data', apiBaseUrl), {
|
||||
signal,
|
||||
})
|
||||
return readJson<DataResponse>(response)
|
||||
},
|
||||
retry: false,
|
||||
staleTime: QUERY_STALE_TIME,
|
||||
} satisfies CreateQueryOptions<
|
||||
DataResponse,
|
||||
Error,
|
||||
DataResponse,
|
||||
DataResponse,
|
||||
typeof DATA_QUERY_KEY
|
||||
>
|
||||
}
|
||||
167
examples/lit/ssr/src/app.ts
Normal file
167
examples/lit/ssr/src/app.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { LitElement, css, html } from 'lit'
|
||||
import {
|
||||
createQueryController,
|
||||
type QueryClient,
|
||||
type QueryResultAccessor,
|
||||
} from '@tanstack/lit-query'
|
||||
import { createDataQueryOptions, type DataResponse } from './api.js'
|
||||
|
||||
const ssrQueryControllerCreationCounts = new WeakMap<QueryClient, number>()
|
||||
|
||||
function incrementSsrQueryControllerCreationCount(
|
||||
queryClient: QueryClient,
|
||||
): void {
|
||||
ssrQueryControllerCreationCounts.set(
|
||||
queryClient,
|
||||
(ssrQueryControllerCreationCounts.get(queryClient) ?? 0) + 1,
|
||||
)
|
||||
}
|
||||
|
||||
export function resetSsrQueryControllerCreationCount(
|
||||
queryClient: QueryClient,
|
||||
): void {
|
||||
ssrQueryControllerCreationCounts.set(queryClient, 0)
|
||||
}
|
||||
|
||||
export function getSsrQueryControllerCreationCount(
|
||||
queryClient: QueryClient,
|
||||
): number {
|
||||
return ssrQueryControllerCreationCounts.get(queryClient) ?? 0
|
||||
}
|
||||
|
||||
export class SsrApp extends LitElement {
|
||||
static properties = {
|
||||
apiBaseUrl: { attribute: 'api-base-url' },
|
||||
queryClient: { attribute: false },
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
color: #1f2937;
|
||||
display: block;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
max-width: 32rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
article {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #111827;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
margin-top: 1rem;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
`
|
||||
|
||||
apiBaseUrl = ''
|
||||
queryClient?: QueryClient
|
||||
|
||||
private dataQuery?: QueryResultAccessor<DataResponse, Error>
|
||||
|
||||
protected override willUpdate(): void {
|
||||
if (!this.dataQuery && this.queryClient) {
|
||||
incrementSsrQueryControllerCreationCount(this.queryClient)
|
||||
this.dataQuery = createQueryController(
|
||||
this,
|
||||
createDataQueryOptions(this.apiBaseUrl),
|
||||
this.queryClient,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private readonly handleRefetch = (): void => {
|
||||
void this.dataQuery?.refetch()
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
if (!this.dataQuery) {
|
||||
return html`
|
||||
<article data-testid="content">
|
||||
<h1>Lit Query SSR</h1>
|
||||
<p data-testid="status">Loading...</p>
|
||||
</article>
|
||||
`
|
||||
}
|
||||
|
||||
const query = this.dataQuery()
|
||||
|
||||
if (query.isPending) {
|
||||
return html`
|
||||
<article data-testid="content">
|
||||
<h1>Lit Query SSR</h1>
|
||||
<p data-testid="status">Loading...</p>
|
||||
</article>
|
||||
`
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return html`
|
||||
<article data-testid="content">
|
||||
<h1>Lit Query SSR</h1>
|
||||
<p data-testid="status">Error</p>
|
||||
<p data-testid="error-message">${query.error?.message}</p>
|
||||
</article>
|
||||
`
|
||||
}
|
||||
|
||||
return html`
|
||||
<article data-testid="content">
|
||||
<h1>Lit Query SSR</h1>
|
||||
<p data-testid="status">${query.isFetching ? 'Refreshing' : 'Ready'}</p>
|
||||
<p data-testid="message">${query.data.message}</p>
|
||||
<p data-testid="request-count">
|
||||
Request count: ${query.data.requestCount}
|
||||
</p>
|
||||
<p data-testid="served-at">Served at: ${query.data.servedAt}</p>
|
||||
<button
|
||||
data-testid="refetch-button"
|
||||
?disabled=${query.isFetching}
|
||||
@click=${this.handleRefetch}
|
||||
>
|
||||
Refetch
|
||||
</button>
|
||||
</article>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('ssr-app')) {
|
||||
customElements.define('ssr-app', SsrApp)
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ssr-app': SsrApp
|
||||
}
|
||||
}
|
||||
63
examples/lit/ssr/src/main.ts
Normal file
63
examples/lit/ssr/src/main.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import '@lit-labs/ssr-client/lit-element-hydrate-support.js'
|
||||
|
||||
import { QueryClient, hydrate, type DehydratedState } from '@tanstack/lit-query'
|
||||
import { QUERY_STALE_TIME } from './api.js'
|
||||
|
||||
type HydratableSsrApp = HTMLElement & {
|
||||
queryClient?: QueryClient
|
||||
}
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: QUERY_STALE_TIME,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function readDehydratedState(): DehydratedState {
|
||||
const stateElement = document.getElementById('__QUERY_STATE__')
|
||||
if (!stateElement) {
|
||||
throw new Error('Missing dehydrated state script.')
|
||||
}
|
||||
|
||||
const stateText = stateElement.textContent?.trim() ?? 'null'
|
||||
return JSON.parse(stateText) as DehydratedState
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (document.readyState === 'loading') {
|
||||
await new Promise<void>((resolve) => {
|
||||
document.addEventListener('DOMContentLoaded', () => resolve(), {
|
||||
once: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const appElement = document.querySelector(
|
||||
'ssr-app',
|
||||
) as HydratableSsrApp | null
|
||||
if (!appElement) {
|
||||
throw new Error('Expected the SSR app element to exist before hydration.')
|
||||
}
|
||||
|
||||
const queryClient = createQueryClient()
|
||||
queryClient.mount()
|
||||
hydrate(queryClient, readDehydratedState())
|
||||
appElement.queryClient = queryClient
|
||||
|
||||
window.addEventListener(
|
||||
'pagehide',
|
||||
() => {
|
||||
queryClient.unmount()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
|
||||
await import('./app.js')
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
14
examples/lit/ssr/tsconfig.json
Normal file
14
examples/lit/ssr/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"include": ["config/**/*.d.ts", "src/**/*.ts", "vite.config.ts"]
|
||||
}
|
||||
18
examples/lit/ssr/vite.config.ts
Normal file
18
examples/lit/ssr/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { SSR_PORT } from './config/ports.js'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
target: 'es2022',
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: SSR_PORT,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
host: '127.0.0.1',
|
||||
port: SSR_PORT,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user