@talelk/newui-data 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +170 -0
  5. package/src/aggregate.test.ts +231 -0
  6. package/src/aggregate.ts +320 -0
  7. package/src/config-store.test.ts +157 -0
  8. package/src/config-store.ts +295 -0
  9. package/src/csv-export.test.ts +156 -0
  10. package/src/csv-export.ts +129 -0
  11. package/src/dashboard.test.tsx +285 -0
  12. package/src/dashboard.tsx +381 -0
  13. package/src/download.test.ts +14 -0
  14. package/src/download.ts +39 -0
  15. package/src/exports.test.ts +135 -0
  16. package/src/form/autosave.test.ts +186 -0
  17. package/src/form/autosave.ts +194 -0
  18. package/src/form/config.test.tsx +288 -0
  19. package/src/form/config.ts +187 -0
  20. package/src/form/form.test.tsx +102 -0
  21. package/src/form/form.tsx +205 -0
  22. package/src/form/index.ts +3 -0
  23. package/src/format.test.ts +22 -0
  24. package/src/format.ts +36 -0
  25. package/src/index.ts +10 -0
  26. package/src/query/client.tsx +53 -0
  27. package/src/query/collection.test.tsx +278 -0
  28. package/src/query/collection.ts +207 -0
  29. package/src/query/index.ts +4 -0
  30. package/src/query/repository.ts +30 -0
  31. package/src/query/server-table.test.ts +136 -0
  32. package/src/query/server-table.ts +181 -0
  33. package/src/resource/client.test.ts +173 -0
  34. package/src/resource/client.ts +307 -0
  35. package/src/resource/connect-realtime.test.ts +135 -0
  36. package/src/resource/connect-realtime.ts +121 -0
  37. package/src/resource/document.test.ts +151 -0
  38. package/src/resource/document.ts +198 -0
  39. package/src/resource/idb.test.ts +169 -0
  40. package/src/resource/idb.ts +167 -0
  41. package/src/resource/index.ts +77 -0
  42. package/src/resource/list.test.ts +150 -0
  43. package/src/resource/list.ts +230 -0
  44. package/src/resource/local.test.ts +111 -0
  45. package/src/resource/local.ts +155 -0
  46. package/src/resource/realtime.test.ts +331 -0
  47. package/src/resource/realtime.ts +287 -0
  48. package/src/resource/resource.test.ts +186 -0
  49. package/src/resource/resource.ts +213 -0
  50. package/src/resource/useCall.test.ts +60 -0
  51. package/src/resource/useCall.ts +74 -0
  52. package/src/resource/useDoc.test.ts +31 -0
  53. package/src/resource/useDoc.ts +23 -0
  54. package/src/resource/useDoctype.test.ts +104 -0
  55. package/src/resource/useDoctype.ts +128 -0
  56. package/src/resource/useList.test.ts +32 -0
  57. package/src/resource/useList.ts +22 -0
  58. package/src/resource/useNewDoc.test.ts +67 -0
  59. package/src/resource/useNewDoc.ts +78 -0
  60. package/src/select.test.ts +13 -0
  61. package/src/select.ts +19 -0
  62. package/src/table-persist.test.ts +258 -0
  63. package/src/table-persist.ts +371 -0
  64. package/src/undo.test.ts +24 -0
  65. package/src/undo.ts +37 -0
  66. package/src/url-serialize.ts +93 -0
  67. package/src/url-state.test.ts +183 -0
  68. package/src/url-state.ts +168 -0
@@ -0,0 +1,205 @@
1
+ import {
2
+ createForm,
3
+ type StandardSchemaV1,
4
+ standardSchemaValidators,
5
+ } from '@tanstack/solid-form'
6
+ import { type Accessor, type JSX, splitProps } from 'solid-js'
7
+ import {
8
+ type FieldConfig,
9
+ hiddenPaths,
10
+ isUnderHidden,
11
+ pruneHiddenValues,
12
+ } from './config'
13
+
14
+ /**
15
+ * The form surface: the kit's answer to "how does a page submit".
16
+ *
17
+ * TanStack Form stays the engine — validators, field state, reset. What this
18
+ * adds is the three things every page was writing by hand: a submit lifecycle
19
+ * the button can read, a `<form>` that never lets the browser's native
20
+ * validation paint first, and one place that turns a field's error list into the
21
+ * single `error` string the kit's `Field` contract takes.
22
+ */
23
+
24
+ export interface AppFormOptions<T extends object> {
25
+ defaultValues: T
26
+ /**
27
+ * Runs on submit. While the returned promise is pending the form reports
28
+ * `submitting()`, which is what a submit button's `loading` reads.
29
+ */
30
+ onSubmit: (value: T, api: { reset: () => void }) => void | Promise<void>
31
+ /**
32
+ * A Standard Schema (Zod, Valibot, Ark) — the document, validated on change
33
+ * and on submit, with issues mapped onto fields. The schema must validate
34
+ * synchronously; an async one belongs in the submit handler.
35
+ */
36
+ schema?: StandardSchemaV1<T, T>
37
+ /**
38
+ * The field map: how each field renders, keyed by static path (`smtp.host`,
39
+ * never `items[1]` — a repeater's row fields go under its `fields`). The
40
+ * engine reads `when` (for pruning and for dropping issues on hidden paths)
41
+ * and `dependsOn` (for revalidation); the rest is renderer vocabulary that
42
+ * the `ConfigForm` organism consumes from the same object.
43
+ */
44
+ ui?: Record<string, FieldConfig<T>>
45
+ /**
46
+ * Drop fields hidden by `when` from the submitted document.
47
+ * @default true
48
+ */
49
+ pruneHidden?: boolean
50
+ }
51
+
52
+ /** The `error` prop of a field, from TanStack's error list. */
53
+ export function fieldError(field: {
54
+ state: { meta: { errors: unknown[] } }
55
+ }): string | undefined {
56
+ const { errors } = field.state.meta
57
+ if (errors.length === 0) return undefined
58
+ const messages = errors.map((error) =>
59
+ typeof error === 'string'
60
+ ? error
61
+ : error && typeof error === 'object' && 'message' in error
62
+ ? String((error as { message: unknown }).message)
63
+ : String(error),
64
+ )
65
+ // A field usually declares the same message for `onChange` and `onSubmit`
66
+ // (validate as you type, and again on the way out). TanStack keeps one entry
67
+ // per validator, so the naive join prints the sentence twice.
68
+ return [...new Set(messages)].join(' ')
69
+ }
70
+
71
+ /**
72
+ * Throws on a field-map key that names an array position (`items[1]`). Keys
73
+ * are static paths; positions only arise when a repeater expands its rows.
74
+ */
75
+ function assertStaticPaths(
76
+ ui: Record<string, { fields?: Record<string, unknown> }>,
77
+ ): void {
78
+ for (const [key, config] of Object.entries(ui)) {
79
+ if (key.includes('['))
80
+ throw new Error(
81
+ `newui form: field-map key "${key}" must be a static path — declare row fields under a repeater's \`fields\` instead of indexing.`,
82
+ )
83
+ if (config.fields)
84
+ assertStaticPaths(
85
+ config.fields as Record<string, { fields?: Record<string, unknown> }>,
86
+ )
87
+ }
88
+ }
89
+
90
+ export function createAppForm<T extends object>(options: AppFormOptions<T>) {
91
+ const ui = options.ui ?? {}
92
+ assertStaticPaths(ui)
93
+ const prune = options.pruneHidden !== false
94
+ const hasDeps = Object.values(ui).some((config) => config.dependsOn?.length)
95
+
96
+ /** The document the backend should see: hidden fields are not part of it. */
97
+ const submitted = (values: T): T =>
98
+ prune ? pruneHiddenValues(values, ui) : values
99
+
100
+ /**
101
+ * The schema validates the document that would be submitted, and issues on
102
+ * hidden paths are dropped: a field the user cannot see must not block the
103
+ * submit, and a hidden setting is not required of the document.
104
+ */
105
+ const schema = options.schema
106
+ const schemaValidator = schema
107
+ ? ({ value }: { value: unknown }) => {
108
+ const values = value as T
109
+ const issues = standardSchemaValidators.validate(
110
+ { value: submitted(values), validationSource: 'form' },
111
+ schema,
112
+ )
113
+ if (!issues?.fields) return issues
114
+ const hidden = hiddenPaths(values, ui)
115
+ if (hidden.length === 0) return issues
116
+ // The adapter reports every issue twice — per field and form-wide —
117
+ // so both maps are filtered, or a hidden issue still blocks submit.
118
+ const visible = (map: Record<string, unknown>) =>
119
+ Object.fromEntries(
120
+ Object.entries(map).filter(
121
+ ([path]) => !isUnderHidden(path, hidden),
122
+ ),
123
+ )
124
+ const fields = visible(issues.fields)
125
+ if (Object.keys(fields).length === 0) return undefined
126
+ return {
127
+ ...issues,
128
+ form:
129
+ issues.form && typeof issues.form === 'object'
130
+ ? visible(issues.form as Record<string, unknown>)
131
+ : issues.form,
132
+ fields,
133
+ }
134
+ }
135
+ : undefined
136
+
137
+ const form = createForm(() => ({
138
+ defaultValues: options.defaultValues,
139
+ validators: schemaValidator
140
+ ? { onChange: schemaValidator, onSubmit: schemaValidator }
141
+ : undefined,
142
+ // `dependsOn` compiles onto TanStack's listeners: a dependency's change
143
+ // revalidates the dependent field with the `dynamic` cause. Visibility
144
+ // itself needs no wiring — `when` reads the form's reactive values.
145
+ listeners: hasDeps
146
+ ? {
147
+ onChange: ({ formApi, fieldApi }) => {
148
+ for (const [path, config] of Object.entries(ui)) {
149
+ if (config.dependsOn?.includes(String(fieldApi.name))) {
150
+ void formApi.validateField(path, 'dynamic')
151
+ }
152
+ }
153
+ },
154
+ }
155
+ : undefined,
156
+ onSubmit: async ({ value, formApi }) => {
157
+ await options.onSubmit(submitted(value as T), {
158
+ reset: () => formApi.reset(),
159
+ })
160
+ },
161
+ }))
162
+ const submitting = form.useSelector((state) => state.isSubmitting)
163
+ return {
164
+ form,
165
+ /** True while the submit handler's promise is pending. */
166
+ submitting: submitting as Accessor<boolean>,
167
+ /** The field map, resolved. Pass it to `ConfigForm`'s `fields`. */
168
+ ui,
169
+ /**
170
+ * The document the backend sees for these values: hidden fields pruned
171
+ * unless `pruneHidden` is off. Submit sends it; so does `createAutoSave`.
172
+ */
173
+ document: submitted,
174
+ }
175
+ }
176
+
177
+ /**
178
+ * The `<form>` element, wired.
179
+ *
180
+ * `noValidate` because the kit's own field messages are the error UI; the native
181
+ * bubble would appear first, say less, and disappear on the next keystroke.
182
+ * `preventDefault` because this is a single-page app: a real submit would
183
+ * navigate.
184
+ */
185
+ export function Form(props: {
186
+ form: { handleSubmit: () => unknown }
187
+ children: JSX.Element
188
+ class?: string
189
+ }): JSX.Element {
190
+ const [local, rest] = splitProps(props, ['form', 'children', 'class'])
191
+ return (
192
+ <form
193
+ {...rest}
194
+ noValidate
195
+ class={local.class}
196
+ onSubmit={(event) => {
197
+ event.preventDefault()
198
+ event.stopPropagation()
199
+ void local.form.handleSubmit()
200
+ }}
201
+ >
202
+ {local.children}
203
+ </form>
204
+ )
205
+ }
@@ -0,0 +1,3 @@
1
+ export * from './autosave'
2
+ export * from './config'
3
+ export * from './form'
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { bytes, count, isEmail, usd, usdK } from './format'
3
+
4
+ describe('format', () => {
5
+ it('formats money, counts and sizes the way the pages did', () => {
6
+ expect(usd(128430)).toBe('$128,430')
7
+ expect(usd(0)).toBe('$0')
8
+ expect(usdK(42500)).toBe('$43k')
9
+ expect(usdK(400)).toBe('$0k')
10
+ expect(count(128430)).toBe('128,430')
11
+ expect(bytes(512)).toBe('512 B')
12
+ expect(bytes(2048)).toBe('2.0 KB')
13
+ expect(bytes(5 * 1024 * 1024)).toBe('5.0 MB')
14
+ })
15
+
16
+ it('accepts ordinary addresses and rejects the rest', () => {
17
+ expect(isEmail('rhea@example.com')).toBe(true)
18
+ expect(isEmail('rhea@example')).toBe(false)
19
+ expect(isEmail('rhea kapoor@example.com')).toBe(false)
20
+ expect(isEmail('')).toBe(false)
21
+ })
22
+ })
package/src/format.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared formatting: money, counts, sizes, addresses.
3
+ *
4
+ * Built once at module scope — an `Intl.NumberFormat` per render is the kind of
5
+ * cost that never shows in a profile and always shows in a list. The examples
6
+ * had four near-identical USD copies; this is the one.
7
+ */
8
+
9
+ const usdFormat = new Intl.NumberFormat('en-US', {
10
+ style: 'currency',
11
+ currency: 'USD',
12
+ maximumFractionDigits: 0,
13
+ })
14
+
15
+ /** Whole-dollar USD: `usd(128430)` → `$128,430`. */
16
+ export const usd = (value: number): string => usdFormat.format(value)
17
+
18
+ /** Compact axis form: `usdK(42500)` → `$43k`. */
19
+ export const usdK = (value: number): string => `$${Math.round(value / 1000)}k`
20
+
21
+ const countFormat = new Intl.NumberFormat('en-US')
22
+
23
+ /** Grouped integer: `count(128430)` → `128,430`. */
24
+ export const count = (value: number): string => countFormat.format(value)
25
+
26
+ /** Human file size: `bytes(2048)` → `2.0 KB`. */
27
+ export const bytes = (value: number): string =>
28
+ value < 1024
29
+ ? `${value} B`
30
+ : value < 1024 * 1024
31
+ ? `${(value / 1024).toFixed(1)} KB`
32
+ : `${(value / (1024 * 1024)).toFixed(1)} MB`
33
+
34
+ /** The one address check every example form shares. */
35
+ export const isEmail = (value: string): boolean =>
36
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from './aggregate'
2
+ export * from './config-store'
3
+ export * from './csv-export'
4
+ export * from './dashboard'
5
+ export * from './download'
6
+ export * from './format'
7
+ export * from './select'
8
+ export * from './table-persist'
9
+ export * from './undo'
10
+ export * from './url-state'
@@ -0,0 +1,53 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
2
+ import { type JSX, onCleanup, untrack } from 'solid-js'
3
+
4
+ /**
5
+ * The query client an app boots with.
6
+ *
7
+ * The defaults are the kit's opinion, stated once: local data is not a
8
+ * dashboard (no refetch on window focus), a transient failure is worth one retry,
9
+ * and an error is data the page renders rather than an exception that tears the
10
+ * tree down. A page overrides any of them per query.
11
+ */
12
+ export interface DataClientOptions {
13
+ /** How long a list is fresh. Default `30_000`. */
14
+ staleTime?: number
15
+ /** Retries after the first failure. Default `1`. */
16
+ retry?: number
17
+ }
18
+
19
+ export function createDataClient(options: DataClientOptions = {}): QueryClient {
20
+ return new QueryClient({
21
+ defaultOptions: {
22
+ queries: {
23
+ staleTime: options.staleTime ?? 30_000,
24
+ retry: options.retry ?? 1,
25
+ refetchOnWindowFocus: false,
26
+ throwOnError: false,
27
+ },
28
+ mutations: { retry: 0 },
29
+ },
30
+ })
31
+ }
32
+
33
+ /**
34
+ * The provider a page wraps its workspace in.
35
+ *
36
+ * Pass `client` to share one (a workspace with several panes shares its cache);
37
+ * omit it and the provider owns a client and clears it on unmount, so a route
38
+ * change never leaves timers or caches behind.
39
+ */
40
+ export function DataProvider(props: {
41
+ client?: QueryClient
42
+ options?: DataClientOptions
43
+ children: JSX.Element
44
+ }): JSX.Element {
45
+ // Chosen once, by design: swapping a provider's client mid-tree would orphan
46
+ // the cache every consumer is reading from.
47
+ const provided = untrack(() => props.client)
48
+ const client = provided ?? createDataClient(untrack(() => props.options))
49
+ if (!provided) onCleanup(() => client.clear())
50
+ return (
51
+ <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
52
+ )
53
+ }
@@ -0,0 +1,278 @@
1
+ import { render, screen, waitFor } from '@solidjs/testing-library'
2
+ import type { QueryKey } from '@tanstack/solid-query'
3
+ import { createSignal, For, type JSX, untrack } from 'solid-js'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { DataProvider } from './client'
6
+ import {
7
+ type Collection,
8
+ createCollection,
9
+ createListQuery,
10
+ } from './collection'
11
+ import type { Repository } from './repository'
12
+
13
+ interface Row {
14
+ id: string
15
+ title: string
16
+ }
17
+
18
+ interface Deferred<T> {
19
+ promise: Promise<T>
20
+ resolve: (value: T) => void
21
+ reject: (error: unknown) => void
22
+ }
23
+
24
+ function deferred<T>(): Deferred<T> {
25
+ let resolve!: (value: T) => void
26
+ let reject!: (error: unknown) => void
27
+ const promise = new Promise<T>((ok, fail) => {
28
+ resolve = ok
29
+ reject = fail
30
+ })
31
+ return { promise, resolve, reject }
32
+ }
33
+
34
+ function makeRepository(initial: Row[]) {
35
+ let rows = [...initial]
36
+ const repository: Repository<Row, { title: string }> = {
37
+ list: async () => [...rows],
38
+ create: async (input) => {
39
+ const row = { id: `R-${rows.length + 1}`, title: input.title }
40
+ rows = [row, ...rows]
41
+ return row
42
+ },
43
+ update: vi.fn(async (id, patch) => {
44
+ rows = rows.map((row) => (row.id === id ? { ...row, ...patch } : row))
45
+ return rows.find((row) => row.id === id) as Row
46
+ }),
47
+ remove: async (ids) => {
48
+ const gone = new Set(ids)
49
+ rows = rows.filter((row) => !gone.has(row.id))
50
+ },
51
+ }
52
+ return { repository, rows: () => rows }
53
+ }
54
+
55
+ function Harness(props: {
56
+ repository: Repository<Row, { title: string }>
57
+ ready: (collection: Collection<Row, { title: string }>) => void
58
+ interval?: number | false
59
+ }): JSX.Element {
60
+ const collection = createCollection(() => ({
61
+ key: ['rows'],
62
+ repository: props.repository,
63
+ retry: false,
64
+ ...(props.interval !== undefined
65
+ ? { refetchInterval: props.interval }
66
+ : {}),
67
+ }))
68
+ untrack(() => props.ready(collection))
69
+ return (
70
+ <ul>
71
+ <For each={collection.query.rows()}>{(row) => <li>{row.title}</li>}</For>
72
+ </ul>
73
+ )
74
+ }
75
+
76
+ function mount(
77
+ repository: Repository<Row, { title: string }>,
78
+ interval?: number | false,
79
+ ) {
80
+ let collection!: Collection<Row, { title: string }>
81
+ render(() => (
82
+ <DataProvider>
83
+ <Harness
84
+ repository={repository}
85
+ interval={interval}
86
+ ready={(value) => (collection = value)}
87
+ />
88
+ </DataProvider>
89
+ ))
90
+ return () => collection
91
+ }
92
+
93
+ describe('createCollection', () => {
94
+ it('loads the list and reports its pending and error states', async () => {
95
+ const failing = deferred<Row[]>()
96
+ const { repository } = makeRepository([{ id: '1', title: 'One' }])
97
+ const broken: Repository<Row, { title: string }> = {
98
+ ...repository,
99
+ list: () => failing.promise,
100
+ }
101
+ const collection = mount(broken)
102
+ expect(collection().query.isPending()).toBe(true)
103
+
104
+ failing.reject(new Error('The service is down'))
105
+ await waitFor(() => expect(collection().query.isError()).toBe(true))
106
+ expect(collection().query.error()?.message).toBe('The service is down')
107
+ })
108
+
109
+ it('patches optimistically and restores the list when the request fails', async () => {
110
+ const gate = deferred<Row>()
111
+ const { repository } = makeRepository([
112
+ { id: '1', title: 'One' },
113
+ { id: '2', title: 'Two' },
114
+ ])
115
+ const gated: Repository<Row, { title: string }> = {
116
+ ...repository,
117
+ update: (id, patch) => {
118
+ gate.promise.then(
119
+ () => repository.update(id, patch),
120
+ () => {},
121
+ )
122
+ return gate.promise
123
+ },
124
+ }
125
+ const collection = mount(gated)
126
+ await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
127
+
128
+ collection().update.mutate({ id: '1', patch: { title: 'Changed' } })
129
+ // On screen before the request settles — that is the point of optimistic.
130
+ await waitFor(() => expect(screen.getByText('Changed')).toBeTruthy())
131
+
132
+ gate.reject(new Error('nope'))
133
+ await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
134
+ expect(screen.queryByText('Changed')).toBeNull()
135
+ })
136
+
137
+ it('creates through the repository and refreshes the list', async () => {
138
+ const { repository, rows } = makeRepository([{ id: '1', title: 'One' }])
139
+ const collection = mount(repository)
140
+ await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
141
+
142
+ await collection().create.mutateAsync({ title: 'New' })
143
+ expect(rows()[0]?.title).toBe('New')
144
+ await waitFor(() => expect(screen.getByText('New')).toBeTruthy())
145
+ })
146
+
147
+ it('completes many at once and removes through the same seam', async () => {
148
+ const { repository } = makeRepository([
149
+ { id: '1', title: 'One' },
150
+ { id: '2', title: 'Two' },
151
+ ])
152
+ const collection = mount(repository)
153
+ await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
154
+
155
+ await collection().updateMany.mutateAsync({
156
+ ids: ['1', '2'],
157
+ patch: { title: 'Done' },
158
+ })
159
+ await waitFor(() => expect(screen.getAllByText('Done')).toHaveLength(2))
160
+
161
+ await collection().remove.mutateAsync({ ids: ['1', '2'] })
162
+ await waitFor(() => expect(screen.queryByText('Done')).toBeNull())
163
+ })
164
+
165
+ it('polls the list on the interval it is given', async () => {
166
+ const { repository } = makeRepository([{ id: '1', title: 'One' }])
167
+ const list = vi.fn(repository.list)
168
+ const polled: Repository<Row, { title: string }> = { ...repository, list }
169
+ mount(polled, 30)
170
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(1))
171
+
172
+ // The explicit dial: focus-refetch stays off, the interval does the work.
173
+ await waitFor(
174
+ () => expect(list.mock.calls.length).toBeGreaterThanOrEqual(3),
175
+ {
176
+ timeout: 2000,
177
+ },
178
+ )
179
+ })
180
+
181
+ it('never polls when refetchInterval is false', async () => {
182
+ const { repository } = makeRepository([{ id: '1', title: 'One' }])
183
+ const list = vi.fn(repository.list)
184
+ const quiet: Repository<Row, { title: string }> = { ...repository, list }
185
+ mount(quiet, false)
186
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(1))
187
+
188
+ await new Promise((resolve) => setTimeout(resolve, 120))
189
+ expect(list).toHaveBeenCalledTimes(1)
190
+ })
191
+ })
192
+
193
+ describe('createListQuery keepPreviousData', () => {
194
+ /** One deferred response per key tag, so a key change gates on demand. */
195
+ function keyedRepository(tag: () => string) {
196
+ const pending = new Map<string, Deferred<Row[]>>()
197
+ const repository: Repository<Row, never> = {
198
+ list: () => {
199
+ const gate = deferred<Row[]>()
200
+ pending.set(tag(), gate)
201
+ return gate.promise
202
+ },
203
+ create: async () => {
204
+ throw new Error('not used')
205
+ },
206
+ update: async () => {
207
+ throw new Error('not used')
208
+ },
209
+ remove: async () => {},
210
+ }
211
+ return { repository, pending }
212
+ }
213
+
214
+ function ListHarness(props: {
215
+ queryKey: () => QueryKey
216
+ repository: Repository<Row, never>
217
+ keepPreviousData?: boolean
218
+ }): JSX.Element {
219
+ const list = createListQuery(() => ({
220
+ key: props.queryKey(),
221
+ repository: props.repository,
222
+ retry: false,
223
+ ...(props.keepPreviousData ? { keepPreviousData: true } : {}),
224
+ }))
225
+ return (
226
+ <ul>
227
+ <For each={list.rows()}>{(row) => <li>{row.title}</li>}</For>
228
+ </ul>
229
+ )
230
+ }
231
+
232
+ function mountList(keepPreviousData?: boolean) {
233
+ const [key, setKey] = createSignal<QueryKey>(['list', 'a'])
234
+ const { repository, pending } = keyedRepository(() =>
235
+ String(untrack(key)[1]),
236
+ )
237
+ render(() => (
238
+ <DataProvider>
239
+ <ListHarness
240
+ queryKey={key}
241
+ repository={repository}
242
+ keepPreviousData={keepPreviousData}
243
+ />
244
+ </DataProvider>
245
+ ))
246
+ return {
247
+ setKey: (tag: string) => setKey(['list', tag]),
248
+ pending,
249
+ }
250
+ }
251
+
252
+ it('holds the old page while the new key is in flight', async () => {
253
+ const { setKey, pending } = mountList(true)
254
+ await waitFor(() => {
255
+ pending.get('a')?.resolve([{ id: '1', title: 'One' }])
256
+ expect(screen.getByText('One')).toBeTruthy()
257
+ })
258
+
259
+ setKey('b')
260
+ // The request is in flight; the table must not blank.
261
+ await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
262
+ pending.get('b')?.resolve([{ id: '2', title: 'Two' }])
263
+ await waitFor(() => expect(screen.getByText('Two')).toBeTruthy())
264
+ expect(screen.queryByText('One')).toBeNull()
265
+ })
266
+
267
+ it('without it the rows clear while the new key loads', async () => {
268
+ const { setKey, pending } = mountList(false)
269
+ await waitFor(() => {
270
+ pending.get('a')?.resolve([{ id: '1', title: 'One' }])
271
+ expect(screen.getByText('One')).toBeTruthy()
272
+ })
273
+ setKey('b')
274
+ await waitFor(() => expect(screen.queryByText('One')).toBeNull())
275
+ pending.get('b')?.resolve([{ id: '2', title: 'Two' }])
276
+ await waitFor(() => expect(screen.getByText('Two')).toBeTruthy())
277
+ })
278
+ })