@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.
- package/LICENSE +202 -0
- package/README.md +65 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +170 -0
- package/src/aggregate.test.ts +231 -0
- package/src/aggregate.ts +320 -0
- package/src/config-store.test.ts +157 -0
- package/src/config-store.ts +295 -0
- package/src/csv-export.test.ts +156 -0
- package/src/csv-export.ts +129 -0
- package/src/dashboard.test.tsx +285 -0
- package/src/dashboard.tsx +381 -0
- package/src/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +135 -0
- package/src/form/autosave.test.ts +186 -0
- package/src/form/autosave.ts +194 -0
- package/src/form/config.test.tsx +288 -0
- package/src/form/config.ts +187 -0
- package/src/form/form.test.tsx +102 -0
- package/src/form/form.tsx +205 -0
- package/src/form/index.ts +3 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +10 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +278 -0
- package/src/query/collection.ts +207 -0
- package/src/query/index.ts +4 -0
- package/src/query/repository.ts +30 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -0
- package/src/resource/document.test.ts +151 -0
- package/src/resource/document.ts +198 -0
- package/src/resource/idb.test.ts +169 -0
- package/src/resource/idb.ts +167 -0
- package/src/resource/index.ts +77 -0
- package/src/resource/list.test.ts +150 -0
- package/src/resource/list.ts +230 -0
- package/src/resource/local.test.ts +111 -0
- package/src/resource/local.ts +155 -0
- package/src/resource/realtime.test.ts +331 -0
- package/src/resource/realtime.ts +287 -0
- package/src/resource/resource.test.ts +186 -0
- package/src/resource/resource.ts +213 -0
- package/src/resource/useCall.test.ts +60 -0
- package/src/resource/useCall.ts +74 -0
- package/src/resource/useDoc.test.ts +31 -0
- package/src/resource/useDoc.ts +23 -0
- package/src/resource/useDoctype.test.ts +104 -0
- package/src/resource/useDoctype.ts +128 -0
- package/src/resource/useList.test.ts +32 -0
- package/src/resource/useList.ts +22 -0
- package/src/resource/useNewDoc.test.ts +67 -0
- package/src/resource/useNewDoc.ts +78 -0
- package/src/select.test.ts +13 -0
- package/src/select.ts +19 -0
- package/src/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/undo.test.ts +24 -0
- package/src/undo.ts +37 -0
- package/src/url-serialize.ts +93 -0
- package/src/url-state.test.ts +183 -0
- package/src/url-state.ts +168 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The serialization half of URL state, shared by `createUrlState` and by the
|
|
3
|
+
* higher-level primitives that compose it (`createDashboardState`,
|
|
4
|
+
* `createTablePersist`).
|
|
5
|
+
*
|
|
6
|
+
* `encodeValue` is `createUrlState`'s encoding; a composing primitive needs to
|
|
7
|
+
* know what a key serializes to — to prune params that say nothing — so it
|
|
8
|
+
* lives here instead of being copied. The types live here for the same reason:
|
|
9
|
+
* `url-state.ts` re-exports them, so the public surface is unchanged.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A value that serializes into a query parameter. */
|
|
13
|
+
export type UrlStateValue =
|
|
14
|
+
| string
|
|
15
|
+
| number
|
|
16
|
+
| boolean
|
|
17
|
+
| readonly UrlStateValue[]
|
|
18
|
+
| { readonly [key: string]: UrlStateValue }
|
|
19
|
+
|
|
20
|
+
/** The router side of {@link createUrlState}. */
|
|
21
|
+
export interface UrlStateAdapter {
|
|
22
|
+
/**
|
|
23
|
+
* The current query string (`"?a=1"` or `"a=1"`). Read **once** on creation,
|
|
24
|
+
* so first paint renders from the URL and a shared link deep-links without
|
|
25
|
+
* a client redirect.
|
|
26
|
+
*/
|
|
27
|
+
search(): string
|
|
28
|
+
/** Replace the query string. Must not push a history entry. */
|
|
29
|
+
replace(params: URLSearchParams): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** JSON with object keys in sorted order, so a round-trip is stable. */
|
|
33
|
+
function stableStringify(value: unknown): string {
|
|
34
|
+
return JSON.stringify(value, (_key, part: unknown) => {
|
|
35
|
+
if (part !== null && typeof part === 'object' && !Array.isArray(part)) {
|
|
36
|
+
const record = part as Record<string, unknown>
|
|
37
|
+
const sorted: Record<string, unknown> = {}
|
|
38
|
+
for (const key of Object.keys(record).sort()) sorted[key] = record[key]
|
|
39
|
+
return sorted
|
|
40
|
+
}
|
|
41
|
+
return part
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Encode one value for its query parameter. Primitives stay as-is. */
|
|
46
|
+
export function encodeValue(value: UrlStateValue): string {
|
|
47
|
+
if (typeof value === 'string') return value
|
|
48
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
49
|
+
return String(value)
|
|
50
|
+
}
|
|
51
|
+
return stableStringify(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The params an initial record serializes to — what "default" means for a
|
|
56
|
+
* set of keys. A param that still carries its default value says nothing, so
|
|
57
|
+
* it should never reach a shared link.
|
|
58
|
+
*/
|
|
59
|
+
export function defaultsFor(
|
|
60
|
+
initial: Record<string, UrlStateValue>,
|
|
61
|
+
): URLSearchParams {
|
|
62
|
+
const params = new URLSearchParams()
|
|
63
|
+
for (const [key, value] of Object.entries(initial)) {
|
|
64
|
+
params.set(key, encodeValue(value))
|
|
65
|
+
}
|
|
66
|
+
return params
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Drop every param that still says what its default says. Mutates `params`. */
|
|
70
|
+
export function pruneDefaults(
|
|
71
|
+
params: URLSearchParams,
|
|
72
|
+
defaults: URLSearchParams,
|
|
73
|
+
): URLSearchParams {
|
|
74
|
+
for (const [key, value] of [...defaults]) {
|
|
75
|
+
if (params.get(key) === value) params.delete(key)
|
|
76
|
+
}
|
|
77
|
+
return params
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An adapter wrapper that never writes a param back when it still holds its
|
|
82
|
+
* default. The composing primitive owns the keys; the pruning rule is the
|
|
83
|
+
* "link shares only what is not the default" contract, stated once.
|
|
84
|
+
*/
|
|
85
|
+
export function withPruning(
|
|
86
|
+
adapter: UrlStateAdapter,
|
|
87
|
+
defaults: URLSearchParams,
|
|
88
|
+
): UrlStateAdapter {
|
|
89
|
+
return {
|
|
90
|
+
search: () => adapter.search(),
|
|
91
|
+
replace: (params) => adapter.replace(pruneDefaults(params, defaults)),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createRoot } from 'solid-js'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
createUrlState,
|
|
5
|
+
type UrlState,
|
|
6
|
+
type UrlStateAdapter,
|
|
7
|
+
type UrlStateOptions,
|
|
8
|
+
type UrlStateValue,
|
|
9
|
+
} from './url-state'
|
|
10
|
+
|
|
11
|
+
function setup<T extends Record<string, UrlStateValue>>(
|
|
12
|
+
initial: T,
|
|
13
|
+
options?: UrlStateOptions,
|
|
14
|
+
) {
|
|
15
|
+
let dispose!: () => void
|
|
16
|
+
let state!: UrlState<T>
|
|
17
|
+
createRoot((d) => {
|
|
18
|
+
dispose = d
|
|
19
|
+
state = createUrlState(initial, options)
|
|
20
|
+
})
|
|
21
|
+
return { state, dispose }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface FakeAdapter extends UrlStateAdapter {
|
|
25
|
+
/** The params handed to the last `replace`. */
|
|
26
|
+
last(): URLSearchParams
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function adapter(search = ''): FakeAdapter {
|
|
30
|
+
let last = new URLSearchParams(search)
|
|
31
|
+
const replace = vi.fn((params: URLSearchParams) => {
|
|
32
|
+
last = params
|
|
33
|
+
})
|
|
34
|
+
return {
|
|
35
|
+
search: () => search,
|
|
36
|
+
replace,
|
|
37
|
+
last: () => last,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Let queued microtasks (the per-tick `replace`) run. */
|
|
42
|
+
async function flushMicrotasks(): Promise<void> {
|
|
43
|
+
await Promise.resolve()
|
|
44
|
+
await Promise.resolve()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('createUrlState', () => {
|
|
48
|
+
it('behaves as a plain signal without an adapter, touching no URL', async () => {
|
|
49
|
+
const before = window.location.search
|
|
50
|
+
const { state, dispose } = setup({ view: 'list' })
|
|
51
|
+
expect(state.view()).toBe('list')
|
|
52
|
+
|
|
53
|
+
state.set('view', 'board')
|
|
54
|
+
await flushMicrotasks()
|
|
55
|
+
expect(state.view()).toBe('board')
|
|
56
|
+
expect(window.location.search).toBe(before)
|
|
57
|
+
expect(state.params().get('view')).toBe('board')
|
|
58
|
+
dispose()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('reads the URL once on mount, so first paint renders from the link', () => {
|
|
62
|
+
const url = adapter('n=42&tags=["x","y"]&broken=abc')
|
|
63
|
+
const { state, dispose } = setup(
|
|
64
|
+
{ n: 1, tags: [] as string[], broken: 7 },
|
|
65
|
+
{ adapter: url },
|
|
66
|
+
)
|
|
67
|
+
expect(state.n()).toBe(42)
|
|
68
|
+
expect(state.tags()).toEqual(['x', 'y'])
|
|
69
|
+
// An unparseable value is not a crash: the initial value wins.
|
|
70
|
+
expect(state.broken()).toBe(7)
|
|
71
|
+
dispose()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('serializes primitives as-is and objects with stable key order', async () => {
|
|
75
|
+
const url = adapter()
|
|
76
|
+
const { state, dispose } = setup(
|
|
77
|
+
{ page: 1, flag: false as boolean, range: { to: 30, from: 10 } },
|
|
78
|
+
{ adapter: url },
|
|
79
|
+
)
|
|
80
|
+
state.set('page', 3)
|
|
81
|
+
state.set('flag', true)
|
|
82
|
+
state.set('range', { to: 99, from: 0 })
|
|
83
|
+
await flushMicrotasks()
|
|
84
|
+
|
|
85
|
+
expect(url.replace).toHaveBeenCalledTimes(1)
|
|
86
|
+
const params = url.last()
|
|
87
|
+
expect(params.get('page')).toBe('3')
|
|
88
|
+
expect(params.get('flag')).toBe('true')
|
|
89
|
+
expect(params.get('range')).toBe('{"from":0,"to":99}')
|
|
90
|
+
|
|
91
|
+
const roundTrip = setup(
|
|
92
|
+
{ page: 1, flag: false as boolean, range: { to: 0, from: 0 } },
|
|
93
|
+
{ adapter: adapter(params.toString()) },
|
|
94
|
+
)
|
|
95
|
+
expect(roundTrip.state.page()).toBe(3)
|
|
96
|
+
expect(roundTrip.state.flag()).toBe(true)
|
|
97
|
+
expect(roundTrip.state.range()).toEqual({ from: 0, to: 99 })
|
|
98
|
+
dispose()
|
|
99
|
+
roundTrip.dispose()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('treats undefined as absent — removed, never an empty string', async () => {
|
|
103
|
+
const url = adapter('q=hello')
|
|
104
|
+
const { state, dispose } = setup({ q: 'hello' }, { adapter: url })
|
|
105
|
+
state.set('q', undefined)
|
|
106
|
+
await flushMicrotasks()
|
|
107
|
+
|
|
108
|
+
expect(url.last().has('q')).toBe(false)
|
|
109
|
+
expect(url.last().get('q')).toBeNull()
|
|
110
|
+
expect(state.q()).toBe('hello')
|
|
111
|
+
dispose()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('coalesces several writes in one tick into a single replace', async () => {
|
|
115
|
+
const url = adapter()
|
|
116
|
+
const { state, dispose } = setup({ a: 1, b: 2 }, { adapter: url })
|
|
117
|
+
state.set('a', 10)
|
|
118
|
+
state.set('b', 20)
|
|
119
|
+
await flushMicrotasks()
|
|
120
|
+
expect(url.replace).toHaveBeenCalledTimes(1)
|
|
121
|
+
expect(url.last().get('a')).toBe('10')
|
|
122
|
+
expect(url.last().get('b')).toBe('20')
|
|
123
|
+
|
|
124
|
+
state.set('a', 11)
|
|
125
|
+
await flushMicrotasks()
|
|
126
|
+
expect(url.replace).toHaveBeenCalledTimes(2)
|
|
127
|
+
dispose()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('debounces search-like keys, resetting the window on every keystroke', async () => {
|
|
131
|
+
vi.useFakeTimers()
|
|
132
|
+
try {
|
|
133
|
+
const url = adapter()
|
|
134
|
+
const { state, dispose } = setup(
|
|
135
|
+
{ q: '', page: 1 },
|
|
136
|
+
{ adapter: url, debounce: ['q'] },
|
|
137
|
+
)
|
|
138
|
+
state.set('q', 'a')
|
|
139
|
+
expect(url.replace).not.toHaveBeenCalled()
|
|
140
|
+
vi.advanceTimersByTime(200)
|
|
141
|
+
state.set('q', 'ab')
|
|
142
|
+
expect(url.replace).not.toHaveBeenCalled()
|
|
143
|
+
vi.advanceTimersByTime(200)
|
|
144
|
+
expect(url.replace).not.toHaveBeenCalled()
|
|
145
|
+
vi.advanceTimersByTime(100)
|
|
146
|
+
expect(url.replace).toHaveBeenCalledTimes(1)
|
|
147
|
+
expect(url.last().get('q')).toBe('ab')
|
|
148
|
+
|
|
149
|
+
// A non-debounced key still writes immediately.
|
|
150
|
+
state.set('page', 4)
|
|
151
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
152
|
+
expect(url.last().get('page')).toBe('4')
|
|
153
|
+
dispose()
|
|
154
|
+
} finally {
|
|
155
|
+
vi.useRealTimers()
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('honours a custom debounce window', async () => {
|
|
160
|
+
vi.useFakeTimers()
|
|
161
|
+
try {
|
|
162
|
+
const url = adapter()
|
|
163
|
+
const { state, dispose } = setup(
|
|
164
|
+
{ q: '' },
|
|
165
|
+
{ adapter: url, debounce: ['q'], debounceMs: 50 },
|
|
166
|
+
)
|
|
167
|
+
state.set('q', 'x')
|
|
168
|
+
vi.advanceTimersByTime(50)
|
|
169
|
+
expect(url.last().get('q')).toBe('x')
|
|
170
|
+
dispose()
|
|
171
|
+
} finally {
|
|
172
|
+
vi.useRealTimers()
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('exposes params() as a snapshot that cannot corrupt the state', () => {
|
|
177
|
+
const { state, dispose } = setup({ view: 'list' })
|
|
178
|
+
const snapshot = state.params()
|
|
179
|
+
snapshot.set('view', 'tampered')
|
|
180
|
+
expect(state.params().get('view')).toBe('list')
|
|
181
|
+
dispose()
|
|
182
|
+
})
|
|
183
|
+
})
|
package/src/url-state.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { type Accessor, createSignal, onCleanup, type Setter } from 'solid-js'
|
|
2
|
+
import {
|
|
3
|
+
encodeValue,
|
|
4
|
+
type UrlStateAdapter,
|
|
5
|
+
type UrlStateValue,
|
|
6
|
+
} from './url-serialize'
|
|
7
|
+
|
|
8
|
+
export type { UrlStateAdapter, UrlStateValue } from './url-serialize'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* URL state, router-agnostic by contract.
|
|
12
|
+
*
|
|
13
|
+
* `createUrlState` owns the signals whose values serialize to
|
|
14
|
+
* `URLSearchParams`: what makes a page reproducible by link — filters, view,
|
|
15
|
+
* page index, sort, density, time range. The app supplies the adapter, so the
|
|
16
|
+
* kit never imports a router: Solid Start's `useNavigate`, TanStack Router or
|
|
17
|
+
* a `history.replaceState` shim all satisfy {@link UrlStateAdapter}. Without
|
|
18
|
+
* an adapter the primitive is a plain controllable signal — tests and SSR stay
|
|
19
|
+
* clean, and the URL is simply never written.
|
|
20
|
+
*
|
|
21
|
+
* Writes **replace** the query string, never push (the back button leaves the
|
|
22
|
+
* page, it does not walk a filter history), and concurrent writes coalesce
|
|
23
|
+
* into one `replace` per frame. Search-like keys can be debounced so typing
|
|
24
|
+
* does not spam the history.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Options for {@link createUrlState}. */
|
|
28
|
+
export interface UrlStateOptions {
|
|
29
|
+
/** The router bridge. Absent means no URL access at all — pure signals. */
|
|
30
|
+
adapter?: UrlStateAdapter
|
|
31
|
+
/** Keys written after a debounce instead of immediately (search-like keys). */
|
|
32
|
+
debounce?: readonly string[]
|
|
33
|
+
/** Debounce window in ms. Defaults to `300`. */
|
|
34
|
+
debounceMs?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The handle {@link createUrlState} returns: one accessor per key. */
|
|
38
|
+
export type UrlState<T extends Record<string, UrlStateValue>> = {
|
|
39
|
+
[K in keyof T]: Accessor<T[K]>
|
|
40
|
+
} & {
|
|
41
|
+
/** Write one key. `undefined` means absent — it is removed, never `""`. */
|
|
42
|
+
set<K extends keyof T>(key: K, value: T[K] | undefined): void
|
|
43
|
+
/** The params every key serializes to right now (for "copy link"). */
|
|
44
|
+
params(): URLSearchParams
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_DEBOUNCE_MS = 300
|
|
48
|
+
|
|
49
|
+
/** Decode one raw param back, trusting the initial value's type. */
|
|
50
|
+
function decode(raw: string, initial: UrlStateValue): UrlStateValue {
|
|
51
|
+
if (typeof initial === 'string') return raw
|
|
52
|
+
if (typeof initial === 'number') {
|
|
53
|
+
const value = Number(raw)
|
|
54
|
+
return Number.isFinite(value) ? value : initial
|
|
55
|
+
}
|
|
56
|
+
if (typeof initial === 'boolean') return raw === 'true'
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(raw) as UrlStateValue
|
|
59
|
+
} catch {
|
|
60
|
+
return initial
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Controllable signals serialized to `URLSearchParams`.
|
|
66
|
+
*
|
|
67
|
+
* `initial` is what the page renders when the URL says nothing about a key;
|
|
68
|
+
* the URL wins when it does. Keys are the app's, not the table's — `view.sort`
|
|
69
|
+
* rather than `sort` — so one URL can host several surfaces without collisions.
|
|
70
|
+
*/
|
|
71
|
+
export function createUrlState<T extends Record<string, UrlStateValue>>(
|
|
72
|
+
initial: T,
|
|
73
|
+
options: UrlStateOptions = {},
|
|
74
|
+
): UrlState<T> {
|
|
75
|
+
const keys = Object.keys(initial) as (keyof T & string)[]
|
|
76
|
+
const params = new URLSearchParams()
|
|
77
|
+
for (const key of keys) {
|
|
78
|
+
params.set(key, encodeValue(initial[key] as UrlStateValue))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const signals = new Map<
|
|
82
|
+
string,
|
|
83
|
+
[Accessor<UrlStateValue>, Setter<UrlStateValue>]
|
|
84
|
+
>()
|
|
85
|
+
const state: Record<string, unknown> = {}
|
|
86
|
+
for (const key of keys) {
|
|
87
|
+
const [get, set] = createSignal<UrlStateValue>(
|
|
88
|
+
initial[key] as UrlStateValue,
|
|
89
|
+
)
|
|
90
|
+
// Storing the accessor reference, not reading it — `state.view` is the
|
|
91
|
+
// signal, so the reactivity rule sees no tracked read to miss.
|
|
92
|
+
// eslint-disable-next-line solid/reactivity
|
|
93
|
+
signals.set(key, [get, set])
|
|
94
|
+
state[key] = get
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read-once on mount: the URL seeds the signals, then signals drive.
|
|
98
|
+
if (options.adapter) {
|
|
99
|
+
try {
|
|
100
|
+
const search = new URLSearchParams(options.adapter.search())
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
const raw = search.get(key)
|
|
103
|
+
if (raw === null) continue
|
|
104
|
+
const value = decode(raw, initial[key] as UrlStateValue)
|
|
105
|
+
params.set(key, encodeValue(value))
|
|
106
|
+
signals.get(key)?.[1](() => value)
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// An unreadable URL is "absent everywhere": keep the initial values.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const debounceKeys = new Set(options.debounce ?? [])
|
|
114
|
+
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
|
115
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
116
|
+
let scheduled = false
|
|
117
|
+
let dirty = false
|
|
118
|
+
|
|
119
|
+
const flush = () => {
|
|
120
|
+
scheduled = false
|
|
121
|
+
dirty = false
|
|
122
|
+
options.adapter?.replace(new URLSearchParams(params))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Replace-not-push, batched per tick: one `replace` per coalesced write. */
|
|
126
|
+
const schedule = () => {
|
|
127
|
+
dirty = true
|
|
128
|
+
if (!options.adapter || scheduled) return
|
|
129
|
+
scheduled = true
|
|
130
|
+
queueMicrotask(flush)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onCleanup(() => {
|
|
134
|
+
if (timer) clearTimeout(timer)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const setKey = (
|
|
138
|
+
key: keyof T & string,
|
|
139
|
+
value: UrlStateValue | undefined,
|
|
140
|
+
): void => {
|
|
141
|
+
const entry = signals.get(key)
|
|
142
|
+
if (!entry) return
|
|
143
|
+
const [, set] = entry
|
|
144
|
+
if (value === undefined) {
|
|
145
|
+
params.delete(key)
|
|
146
|
+
set(() => initial[key] as UrlStateValue)
|
|
147
|
+
} else {
|
|
148
|
+
params.set(key, encodeValue(value))
|
|
149
|
+
set(() => value)
|
|
150
|
+
}
|
|
151
|
+
if (debounceKeys.has(key)) {
|
|
152
|
+
// A search-like key waits out the debounce window before its value
|
|
153
|
+
// reaches the URL, so typing does not spam the history.
|
|
154
|
+
if (timer) clearTimeout(timer)
|
|
155
|
+
dirty = true
|
|
156
|
+
timer = setTimeout(() => {
|
|
157
|
+
timer = null
|
|
158
|
+
if (dirty) flush()
|
|
159
|
+
}, debounceMs)
|
|
160
|
+
} else {
|
|
161
|
+
schedule()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
state['set'] = setKey
|
|
165
|
+
|
|
166
|
+
state['params'] = () => new URLSearchParams(params)
|
|
167
|
+
return state as unknown as UrlState<T>
|
|
168
|
+
}
|