@talelk/newui-data 1.0.0 → 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.
@@ -0,0 +1,371 @@
1
+ import type { Accessor } from 'solid-js'
2
+ import { type ConfigStorage, createConfigStore } from './config-store'
3
+ import { defaultsFor, withPruning } from './url-serialize'
4
+ import {
5
+ createUrlState,
6
+ type UrlStateAdapter,
7
+ type UrlStateValue,
8
+ } from './url-state'
9
+
10
+ /**
11
+ * What a table remembers, and where it lives.
12
+ *
13
+ * `createTablePersist` splits table state by *who owns the question*:
14
+ *
15
+ * - The **URL half** — sorting, column filters, density, page size — is what
16
+ * makes a link reproducible, so it serializes through `createUrlState`
17
+ * behind the app's router adapter, pruned to only what differs from the
18
+ * default.
19
+ * - The **storage half** — the column layout (visibility, order, sizing,
20
+ * pinning) — is a personal preference that should survive a shared link,
21
+ * so it lives in a versioned `createConfigStore` document.
22
+ *
23
+ * The document carries the whole state, so a returning user gets their sort
24
+ * back even when the URL says nothing; the URL wins when it does. A persisted
25
+ * column that no longer exists in the table is dropped on read, not hidden
26
+ * forever.
27
+ */
28
+
29
+ /** Envelope-free payload version: a document without `v` predates this shape. */
30
+ const TABLE_PERSIST_SCHEMA_VERSION = 1
31
+
32
+ /** The `type` discriminator on the stored envelope. */
33
+ const TABLE_PERSIST_DOC_TYPE = 'table-view'
34
+
35
+ /** How the table's columns are laid out. Mirrors the TanStack column state. */
36
+ export interface TableColumnLayout {
37
+ /** Sparse visibility map — an absent column is visible. */
38
+ visibility: Record<string, boolean>
39
+ /** Left-to-right column ids. */
40
+ order: string[]
41
+ /** Pixel widths, keyed by column id. */
42
+ sizing: Record<string, number>
43
+ /** Pinned column ids, in pinned order. */
44
+ pinning: { left: string[]; right: string[] }
45
+ }
46
+
47
+ /** One sort entry. */
48
+ export type TableSorting = { id: string; desc: boolean }
49
+
50
+ /** One active column filter. */
51
+ export type TableColumnFilter = { id: string; value: UrlStateValue }
52
+
53
+ /** Row density, matching the table organism's axis. */
54
+ export type TableDensity = 'comfortable' | 'compact'
55
+
56
+ /** Everything `createTablePersist` remembers. */
57
+ export interface TablePersistState {
58
+ columns?: TableColumnLayout
59
+ sorting?: TableSorting[]
60
+ columnFilters?: TableColumnFilter[]
61
+ density?: TableDensity
62
+ pageSize?: number
63
+ }
64
+
65
+ /** The stored document: the state plus its payload version. */
66
+ export interface TablePersistDoc extends TablePersistState {
67
+ v?: number
68
+ }
69
+
70
+ /** Options for {@link createTablePersist}. */
71
+ export interface TablePersistOptions {
72
+ /** Stable id — namespaces the URL keys and the document name. */
73
+ id: string
74
+ /** Router bridge for the URL half. Absent means the URL is never touched. */
75
+ url?: { adapter?: UrlStateAdapter }
76
+ /** Backend for the document. Defaults to `localStorage`. */
77
+ storage?: ConfigStorage
78
+ /**
79
+ * Called when a persist fails — a storage backend that rejects the write,
80
+ * or a document that no longer parses. The table keeps working either way;
81
+ * the app decides whether to say so.
82
+ */
83
+ onPersistError?: (error: Error) => void
84
+ }
85
+
86
+ /** The result of {@link createTablePersist}. */
87
+ export interface TablePersist {
88
+ /**
89
+ * Read the persisted state for a table with these column ids. A persisted
90
+ * column that is gone from the table is dropped from the layout and named
91
+ * in `dropped`; new columns are appended to the order, visible.
92
+ */
93
+ hydrate(columns: readonly string[]): TablePersistState & { dropped: string[] }
94
+ /** Write the table's current state — both halves, whatever is present. */
95
+ commit(state: Partial<TablePersistState>): void
96
+ /** Forget everything persisted for this id. */
97
+ reset(): void
98
+ }
99
+
100
+ /** The storage namespace every persisted table shares. */
101
+ const STORE_KEY = 'newui.tables'
102
+
103
+ function isLayout(value: unknown): value is TableColumnLayout {
104
+ if (value === null || typeof value !== 'object') return false
105
+ const layout = value as Partial<TableColumnLayout>
106
+ return (
107
+ typeof layout.visibility === 'object' &&
108
+ layout.visibility !== null &&
109
+ Array.isArray(layout.order) &&
110
+ typeof layout.sizing === 'object' &&
111
+ layout.sizing !== null &&
112
+ typeof layout.pinning === 'object' &&
113
+ layout.pinning !== null &&
114
+ Array.isArray(layout.pinning.left) &&
115
+ Array.isArray(layout.pinning.right)
116
+ )
117
+ }
118
+
119
+ function isSorting(value: unknown): value is TableSorting[] {
120
+ return (
121
+ Array.isArray(value) &&
122
+ value.every(
123
+ (entry) =>
124
+ entry !== null &&
125
+ typeof entry === 'object' &&
126
+ typeof (entry as TableSorting).id === 'string' &&
127
+ typeof (entry as TableSorting).desc === 'boolean',
128
+ )
129
+ )
130
+ }
131
+
132
+ function isColumnFilters(value: unknown): value is TableColumnFilter[] {
133
+ return (
134
+ Array.isArray(value) &&
135
+ value.every(
136
+ (entry) =>
137
+ entry !== null &&
138
+ typeof entry === 'object' &&
139
+ typeof (entry as TableColumnFilter).id === 'string',
140
+ )
141
+ )
142
+ }
143
+
144
+ /**
145
+ * Upgrade anything a store hands back to the current shape. A payload with
146
+ * no `v` predates the versioned document: it wrote `columns` as the visible
147
+ * ids and `sort` instead of `sorting`. Fields that fail validation are
148
+ * dropped rather than trusted — a half-readable document is still better
149
+ * than none.
150
+ */
151
+ function normalizeDoc(payload: unknown): TablePersistState {
152
+ if (payload === null || typeof payload !== 'object') return {}
153
+ const doc = payload as Record<string, unknown>
154
+ const legacy = doc['v'] === undefined
155
+ const state: TablePersistState = {}
156
+
157
+ const sorting = legacy ? doc['sort'] : doc['sorting']
158
+ if (isSorting(sorting)) state.sorting = sorting
159
+
160
+ const filters = doc['columnFilters']
161
+ if (isColumnFilters(filters)) state.columnFilters = filters
162
+
163
+ const density = doc['density']
164
+ if (density === 'comfortable' || density === 'compact')
165
+ state.density = density
166
+
167
+ const pageSize = doc['pageSize']
168
+ if (
169
+ typeof pageSize === 'number' &&
170
+ Number.isInteger(pageSize) &&
171
+ pageSize > 0
172
+ ) {
173
+ state.pageSize = pageSize
174
+ }
175
+
176
+ const columns = doc['columns']
177
+ if (legacy && Array.isArray(columns)) {
178
+ // v0: the array of visible ids was the whole layout.
179
+ const ids = columns.filter((id): id is string => typeof id === 'string')
180
+ state.columns = {
181
+ visibility: Object.fromEntries(ids.map((id) => [id, true])),
182
+ order: ids,
183
+ sizing: {},
184
+ pinning: { left: [], right: [] },
185
+ }
186
+ } else if (isLayout(columns)) {
187
+ state.columns = columns
188
+ }
189
+ return state
190
+ }
191
+
192
+ /**
193
+ * Create the persistence binding for one table. Call it once per table id,
194
+ * `hydrate` with the current column ids before first paint, and `commit`
195
+ * with the table's state whenever it changes.
196
+ */
197
+ export function createTablePersist(options: TablePersistOptions): TablePersist {
198
+ const { id } = options
199
+ const reportError = (error: Error): void => {
200
+ options.onPersistError?.(error)
201
+ }
202
+
203
+ const urlKeys: Record<string, UrlStateValue> = {
204
+ [`${id}.sort`]: [],
205
+ [`${id}.filters`]: [],
206
+ [`${id}.density`]: '',
207
+ [`${id}.pageSize`]: 0,
208
+ }
209
+ const url = createUrlState(urlKeys, {
210
+ ...(options.url?.adapter
211
+ ? {
212
+ adapter: withPruning(options.url.adapter, defaultsFor(urlKeys)),
213
+ }
214
+ : {}),
215
+ })
216
+
217
+ // Reading `createUrlState`'s record by key runs into the
218
+ // `noUncheckedIndexedAccess` wall; this keeps every accessor honest.
219
+ const read = (key: string): UrlStateValue => {
220
+ const accessor = (
221
+ url as unknown as Record<string, Accessor<UrlStateValue> | undefined>
222
+ )[key]
223
+ return accessor ? accessor() : ''
224
+ }
225
+
226
+ const store = createConfigStore({
227
+ key: STORE_KEY,
228
+ ...(options.storage ? { storage: options.storage } : {}),
229
+ migrate: (doc) => normalizeDoc(doc),
230
+ })
231
+
232
+ function readDoc(): TablePersistState {
233
+ const loaded = store.load<TablePersistDoc>(id)
234
+ if (loaded) return normalizeDoc(loaded.doc)
235
+ // Absent is silence; present-but-unreadable is a story the app hears once.
236
+ if (options.storage?.getItem(`${STORE_KEY}:${id}`) !== null) {
237
+ reportError(
238
+ new Error(
239
+ `createTablePersist: the stored document for "${id}" is unreadable and was treated as absent.`,
240
+ ),
241
+ )
242
+ }
243
+ return {}
244
+ }
245
+
246
+ function readUrl(): Partial<TablePersistState> {
247
+ const state: Partial<TablePersistState> = {}
248
+ const sort = read(`${id}.sort`)
249
+ if (isSorting(sort) && sort.length > 0) state.sorting = sort
250
+ const filters = read(`${id}.filters`)
251
+ if (isColumnFilters(filters) && filters.length > 0)
252
+ state.columnFilters = filters
253
+ const density = read(`${id}.density`)
254
+ if (density === 'comfortable' || density === 'compact')
255
+ state.density = density
256
+ const pageSize = read(`${id}.pageSize`)
257
+ if (
258
+ typeof pageSize === 'number' &&
259
+ Number.isInteger(pageSize) &&
260
+ pageSize > 0
261
+ ) {
262
+ state.pageSize = pageSize
263
+ }
264
+ return state
265
+ }
266
+
267
+ /**
268
+ * Fit a persisted layout to the table that exists now: gone columns are
269
+ * dropped, new ones appended visible, everything else in order.
270
+ */
271
+ function fitColumns(
272
+ persisted: TableColumnLayout,
273
+ current: readonly string[],
274
+ dropped: Set<string>,
275
+ ): TableColumnLayout {
276
+ const keep = new Set(current)
277
+ const order: string[] = []
278
+ for (const column of persisted.order) {
279
+ if (typeof column !== 'string') continue
280
+ if (keep.has(column)) {
281
+ if (!order.includes(column)) order.push(column)
282
+ } else {
283
+ dropped.add(column)
284
+ }
285
+ }
286
+ for (const column of current) {
287
+ if (!order.includes(column)) order.push(column)
288
+ }
289
+
290
+ const visibility: Record<string, boolean> = {}
291
+ for (const [column, visible] of Object.entries(persisted.visibility)) {
292
+ if (!keep.has(column)) dropped.add(column)
293
+ else if (typeof visible === 'boolean') visibility[column] = visible
294
+ }
295
+ const sizing: Record<string, number> = {}
296
+ for (const [column, width] of Object.entries(persisted.sizing)) {
297
+ if (!keep.has(column)) dropped.add(column)
298
+ else if (typeof width === 'number' && width > 0) sizing[column] = width
299
+ }
300
+ const pin = (side: readonly unknown[]): string[] =>
301
+ side.filter((column): column is string => {
302
+ if (typeof column !== 'string') return false
303
+ if (keep.has(column)) return true
304
+ dropped.add(column)
305
+ return false
306
+ })
307
+
308
+ return {
309
+ visibility,
310
+ order,
311
+ sizing,
312
+ pinning: {
313
+ left: pin(persisted.pinning.left),
314
+ right: pin(persisted.pinning.right),
315
+ },
316
+ }
317
+ }
318
+
319
+ return {
320
+ hydrate(columns: readonly string[]) {
321
+ const persisted = readDoc()
322
+ const state: TablePersistState & { dropped: string[] } = {
323
+ ...persisted,
324
+ ...readUrl(),
325
+ dropped: [],
326
+ }
327
+ if (persisted.columns) {
328
+ const dropped = new Set(state.dropped)
329
+ state.columns = fitColumns(persisted.columns, columns, dropped)
330
+ state.dropped = [...dropped]
331
+ }
332
+ return state
333
+ },
334
+
335
+ commit(state: Partial<TablePersistState>) {
336
+ if (state.sorting) url.set(`${id}.sort`, state.sorting)
337
+ if (state.columnFilters) url.set(`${id}.filters`, state.columnFilters)
338
+ if (state.density) url.set(`${id}.density`, state.density)
339
+ if (state.pageSize) url.set(`${id}.pageSize`, state.pageSize)
340
+
341
+ const current = readDoc()
342
+ const next: TablePersistDoc = {
343
+ ...current,
344
+ ...state,
345
+ v: TABLE_PERSIST_SCHEMA_VERSION,
346
+ }
347
+ try {
348
+ if (!store.save(id, next, { type: TABLE_PERSIST_DOC_TYPE })) {
349
+ reportError(
350
+ new Error(
351
+ `createTablePersist: the storage backend rejected the write for "${id}" (quota or disabled storage). The table state is not persisted.`,
352
+ ),
353
+ )
354
+ }
355
+ } catch (error) {
356
+ reportError(
357
+ error instanceof Error
358
+ ? error
359
+ : new Error(`createTablePersist: persisting "${id}" failed.`),
360
+ )
361
+ }
362
+ },
363
+
364
+ reset() {
365
+ store.remove(id)
366
+ for (const key of Object.keys(urlKeys)) {
367
+ url.set(key, undefined)
368
+ }
369
+ },
370
+ }
371
+ }
@@ -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
+ })