@humanspeak/svelte-virtual-list 0.3.3 → 0.3.5

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/README.md CHANGED
@@ -259,4 +259,4 @@ MIT © [Humanspeak, Inc.](LICENSE)
259
259
 
260
260
  ## Credits
261
261
 
262
- Made with by [Humanspeak](https://humanspeak.com)
262
+ Made with ❤️ by [Humanspeak](https://humanspeak.com)
@@ -176,6 +176,9 @@
176
176
  import { onMount, tick, untrack } from 'svelte'
177
177
 
178
178
  const rafSchedule = createRafScheduler()
179
+ // Per-instance correction guard to avoid same-frame tug-of-war per viewport
180
+ const GLOBAL_CORRECTION_COOLDOWN = 16
181
+ const lastCorrectionTimestampByViewport = new WeakMap<HTMLElement, number>()
179
182
  // Package-specific debug flag - safe for library distribution
180
183
  // Enable with: PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG=true (preferred) or SVELTE_VIRTUAL_LIST_DEBUG=true
181
184
  // Avoid SvelteKit-only $env imports so library works in non-Kit/Vitest contexts
@@ -251,6 +254,18 @@
251
254
  itemHeight: defaultEstimatedItemHeight,
252
255
  internalDebug: INTERNAL_DEBUG
253
256
  })
257
+ const instanceId = Math.random().toString(36).slice(2, 7)
258
+
259
+ // Centralized debug logger gated by flags
260
+ const log = (tag: string, payload?: unknown) => {
261
+ if (!debug && !INTERNAL_DEBUG) return
262
+ try {
263
+ const ts = new Date().toISOString().split('T')[1]?.replace('Z', '')
264
+ console.info(`[SVL][${instanceId}] ${ts} ${tag}`, payload ?? '')
265
+ } catch {
266
+ // no-op
267
+ }
268
+ }
254
269
 
255
270
  // Dynamic update coordination to avoid UA scroll anchoring interference
256
271
  let suppressBottomAnchoringUntilMs = $state(0)
@@ -339,11 +354,21 @@
339
354
  mode === 'bottomToTop' &&
340
355
  wasAtBottomBeforeHeightChange &&
341
356
  !programmaticScrollInProgress &&
342
- performance.now() >= suppressBottomAnchoringUntilMs &&
343
- !heightManager.isDynamicUpdateInProgress
357
+ performance.now() >= suppressBottomAnchoringUntilMs
344
358
  ) {
359
+ // Prevent same-frame corrections; defer if this viewport just corrected
360
+ const now = performance.now()
361
+ const viewportEl = heightManager.viewport
362
+ const lastCorrectionMs = lastCorrectionTimestampByViewport.get(viewportEl) ?? 0
363
+ if (now - lastCorrectionMs < GLOBAL_CORRECTION_COOLDOWN) {
364
+ suppressBottomAnchoringUntilMs = now + 50
365
+ return
366
+ }
367
+ lastCorrectionTimestampByViewport.set(viewportEl, now)
368
+
345
369
  // Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
346
370
  const approximateScrollTop = Math.max(0, totalHeight() - height)
371
+ log('b2t-correction-approx', { approximateScrollTop })
347
372
  heightManager.viewport.scrollTop = approximateScrollTop
348
373
  heightManager.scrollTop = approximateScrollTop
349
374
 
@@ -353,15 +378,29 @@
353
378
  '[data-original-index="0"]'
354
379
  )
355
380
  if (item0Element) {
356
- // Native browser API handles all positioning edge cases perfectly
357
- item0Element.scrollIntoView({
358
- block: 'end', // Align Item 0 to bottom edge of viewport
359
- behavior: 'smooth', // Smooth animation for better UX
360
- inline: 'nearest' // Minimal horizontal adjustment
361
- })
362
-
381
+ // Verify alignment via rects; if off, perform one-time scrollIntoView
382
+ const contRect = heightManager.viewport.getBoundingClientRect()
383
+ const itemRect = (item0Element as HTMLElement).getBoundingClientRect()
384
+ const tol = 4
385
+ const aligned =
386
+ Math.abs(contRect.y + contRect.height - (itemRect.y + itemRect.height)) <=
387
+ tol
388
+ if (!aligned) {
389
+ // Native browser API handles all positioning edge cases perfectly
390
+ item0Element.scrollIntoView({
391
+ block: 'end', // Align Item 0 to bottom edge of viewport
392
+ behavior: 'smooth', // Smooth animation for better UX
393
+ inline: 'nearest' // Minimal horizontal adjustment
394
+ })
395
+ log('b2t-correction-native', {
396
+ containerBottom: contRect.y + contRect.height,
397
+ itemBottom: itemRect.y + itemRect.height
398
+ })
399
+ }
363
400
  // Sync our internal scroll state with actual DOM position
364
401
  heightManager.scrollTop = heightManager.viewport.scrollTop
402
+ // After peer correction, delay further corrections briefly
403
+ suppressBottomAnchoringUntilMs = performance.now() + 200
365
404
  }
366
405
  })
367
406
 
@@ -445,7 +484,9 @@
445
484
 
446
485
  // Handle height changes for scroll correction (manager totals already updated)
447
486
  if (result.heightChanges.length > 0 && mode === 'bottomToTop') {
448
- handleHeightChangesScrollCorrection(result.heightChanges)
487
+ // Run correction after dynamic update finishes to avoid blocking conditions
488
+ const changes = result.heightChanges
489
+ queueMicrotask(() => handleHeightChangesScrollCorrection(changes))
449
490
  }
450
491
 
451
492
  // TopToBottom: maintain bottom anchoring when total height changes
@@ -770,7 +811,8 @@
770
811
  if (mode === 'bottomToTop') {
771
812
  const delta = lastScrollTopSnapshot - current
772
813
  if (delta > 0.5) {
773
- suppressBottomAnchoringUntilMs = performance.now() + 300
814
+ // Widen suppression to avoid fighting peer instance corrections
815
+ suppressBottomAnchoringUntilMs = performance.now() + 450
774
816
  userHasScrolledAway = true
775
817
  }
776
818
  }
@@ -779,7 +821,7 @@
779
821
  updateDebugTailDistance()
780
822
  if (INTERNAL_DEBUG) {
781
823
  const vr = visibleItems()
782
- console.info('[SVL] onscroll', {
824
+ log('scroll', {
783
825
  mode,
784
826
  scrollTop: heightManager.scrollTop,
785
827
  height,
@@ -808,40 +850,65 @@
808
850
  * @param immediate - Whether to skip the delay (used for resize events)
809
851
  */
810
852
  const updateHeightAndScroll = (immediate = false) => {
853
+ log('updateHeightAndScroll-enter', {
854
+ immediate,
855
+ initialized: heightManager.initialized,
856
+ mode
857
+ })
811
858
  if (!heightManager.initialized && mode === 'bottomToTop') {
859
+ // Deterministic init order: double RAF + microtask, then apply bottom anchoring
812
860
  tick().then(() => {
813
- if (heightManager.isReady) {
814
- const initialHeight = heightManager.container.getBoundingClientRect().height
815
- height = initialHeight
816
-
817
- tick().then(() => {
818
- if (heightManager.isReady) {
819
- const finalHeight =
820
- heightManager.container.getBoundingClientRect().height
821
- height = finalHeight
822
-
823
- const targetScrollTop = calculateScrollPosition(
824
- items.length,
825
- heightManager.averageHeight,
826
- finalHeight
827
- )
828
-
829
- void heightManager.container.offsetHeight
830
-
861
+ requestAnimationFrame(() => {
862
+ requestAnimationFrame(() => {
863
+ if (!heightManager.isReady) return
864
+ const measuredHeight =
865
+ heightManager.container.getBoundingClientRect().height
866
+ height = measuredHeight
867
+ const targetScrollTop = calculateScrollPosition(
868
+ items.length,
869
+ heightManager.averageHeight,
870
+ measuredHeight
871
+ )
872
+ // Instance jitter to avoid same-frame collisions when two lists init together
873
+ const cleanedId = String(instanceId)
874
+ .toLowerCase()
875
+ .replace(/[^a-z0-9]/g, '')
876
+ const suffix = cleanedId.slice(-4)
877
+ const parsed = parseInt(suffix, 36)
878
+ const jitterMs = Number.isNaN(parsed)
879
+ ? Math.floor(Math.random() * 3)
880
+ : parsed % 3
881
+ log('b2t-init', { measuredHeight, targetScrollTop, jitterMs })
882
+ setTimeout(() => {
831
883
  heightManager.viewport.scrollTop = targetScrollTop
832
884
  heightManager.scrollTop = targetScrollTop
833
-
834
885
  requestAnimationFrame(() => {
835
- const currentScroll = heightManager.viewport.scrollTop
836
- if (currentScroll !== heightManager.scrollTop) {
837
- heightManager.viewport.scrollTop = targetScrollTop
838
- heightManager.scrollTop = targetScrollTop
839
- }
840
- heightManager.initialized = true
886
+ // Guard: only transition false -> true to avoid invariant error
887
+ if (!heightManager.initialized) heightManager.initialized = true
888
+ // Post-init verification: ensure item 0 bottom aligns; fallback to native
889
+ tick().then(() => {
890
+ const el = heightManager.viewport.querySelector(
891
+ '[data-original-index="0"]'
892
+ ) as HTMLElement | null
893
+ if (!el) return
894
+ const cont = heightManager.viewport.getBoundingClientRect()
895
+ const r = el.getBoundingClientRect()
896
+ const tol = 4
897
+ const aligned =
898
+ Math.abs(cont.y + cont.height - (r.y + r.height)) <= tol
899
+ if (!aligned) {
900
+ el.scrollIntoView({ block: 'end', inline: 'nearest' })
901
+ heightManager.scrollTop = heightManager.viewport.scrollTop
902
+ log('b2t-init-native-fallback', {
903
+ containerBottom: cont.y + cont.height,
904
+ itemBottom: r.y + r.height
905
+ })
906
+ }
907
+ })
841
908
  })
842
- }
909
+ }, jitterMs)
843
910
  })
844
- }
911
+ })
845
912
  })
846
913
  return
847
914
  }
@@ -859,17 +926,24 @@
859
926
  {
860
927
  setHeight: (h) => (height = h),
861
928
  setScrollTop: (st) => (heightManager.scrollTop = st),
862
- setInitialized: (i) => (heightManager.initialized = i)
929
+ // Guard: respect invariant in ReactiveListManager; avoid re-setting true
930
+ setInitialized: (i) => {
931
+ if (i && heightManager.initialized) return
932
+ heightManager.initialized = i
933
+ }
863
934
  },
864
935
  immediate
865
936
  )
937
+ log('updateHeightAndScroll-exit', { immediate })
866
938
  }
867
939
 
868
940
  // Create itemResizeObserver immediately when in browser
869
941
  if (BROWSER) {
870
942
  // Watch for individual item size changes
871
943
  itemResizeObserver = new ResizeObserver((entries) => {
872
- tick().then(() => {
944
+ // Batch via RAF to avoid thrash across instances
945
+ rafSchedule(() => {
946
+ log('item-resize-observer', { entries: entries.length })
873
947
  let shouldRecalculate = false
874
948
  void visibleItems() // Cache once to avoid reactive loops
875
949
 
@@ -903,9 +977,8 @@
903
977
  }
904
978
 
905
979
  if (shouldRecalculate) {
906
- rafSchedule(() => {
907
- updateHeight()
908
- })
980
+ log('item-resize-recalc')
981
+ updateHeight()
909
982
  }
910
983
  })
911
984
  })
@@ -915,14 +988,25 @@
915
988
  onMount(() => {
916
989
  if (BROWSER) {
917
990
  // Initial setup of heights and scroll position
991
+ log('onMount-enter', { mode, items: items.length })
918
992
  updateHeightAndScroll()
919
993
  // Ensure one initial measurement pass even if no ResizeObserver fires
920
994
  tick().then(() =>
921
- requestAnimationFrame(() => requestAnimationFrame(() => updateHeight()))
995
+ requestAnimationFrame(() =>
996
+ requestAnimationFrame(() => {
997
+ log('post-hydration-measure')
998
+ updateHeight()
999
+ })
1000
+ )
922
1001
  )
923
1002
 
924
1003
  // Watch for container size changes
925
1004
  resizeObserver = new ResizeObserver(() => {
1005
+ if (!heightManager.initialized) {
1006
+ log('container-resize-ignored', 'not-initialized')
1007
+ return
1008
+ }
1009
+ log('container-resize')
926
1010
  updateHeightAndScroll(true)
927
1011
  })
928
1012
 
@@ -4,6 +4,7 @@ export declare class RecomputeScheduler {
4
4
  private isPending;
5
5
  private blockDepth;
6
6
  private timeoutId;
7
+ private rafId;
7
8
  constructor(onRecompute: () => void);
8
9
  schedule: () => void;
9
10
  block: () => void;
@@ -1,12 +1,19 @@
1
+ // RecomputeScheduler
2
+ // -------------------
3
+ // Coalesces recompute requests to the next animation frame in the browser.
4
+ // Falls back to setTimeout(0) in non-browser/jsdom to preserve deterministic tests.
5
+ // Supports temporary blocking to delay recomputation during critical sections.
1
6
  export class RecomputeScheduler {
2
7
  onRecompute;
3
8
  isScheduled = false;
4
9
  isPending = false;
5
10
  blockDepth = 0;
6
11
  timeoutId = null;
12
+ rafId = null;
7
13
  constructor(onRecompute) {
8
14
  this.onRecompute = onRecompute;
9
15
  }
16
+ // Request a recompute. If blocked, mark as pending; otherwise schedule for next frame.
10
17
  schedule = () => {
11
18
  if (this.blockDepth > 0) {
12
19
  this.isPending = true;
@@ -15,16 +22,30 @@ export class RecomputeScheduler {
15
22
  if (this.isScheduled)
16
23
  return;
17
24
  this.isScheduled = true;
18
- if (this.timeoutId) {
19
- clearTimeout(this.timeoutId);
20
- this.timeoutId = null;
25
+ // In jsdom or non-browser, fall back to immediate execution for determinism
26
+ const isBrowser = typeof window !== 'undefined' && typeof requestAnimationFrame === 'function';
27
+ if (!isBrowser) {
28
+ if (this.timeoutId) {
29
+ clearTimeout(this.timeoutId);
30
+ this.timeoutId = null;
31
+ }
32
+ this.timeoutId = setTimeout(() => {
33
+ this.timeoutId = null;
34
+ this.isScheduled = false;
35
+ this.onRecompute();
36
+ }, 0);
37
+ return;
21
38
  }
22
- this.timeoutId = setTimeout(() => {
23
- this.timeoutId = null;
39
+ // Browser path: coalesce with RAF for visual stability across instances
40
+ if (this.rafId !== null)
41
+ cancelAnimationFrame(this.rafId);
42
+ this.rafId = requestAnimationFrame(() => {
43
+ this.rafId = null;
24
44
  this.isScheduled = false;
25
45
  this.onRecompute();
26
- }, 0);
46
+ });
27
47
  };
48
+ // Temporarily block recomputes; any in-flight timers are canceled and a recompute is marked pending.
28
49
  block = () => {
29
50
  this.blockDepth += 1;
30
51
  if (this.timeoutId) {
@@ -33,7 +54,14 @@ export class RecomputeScheduler {
33
54
  this.isScheduled = false;
34
55
  this.isPending = true;
35
56
  }
57
+ if (this.rafId !== null) {
58
+ cancelAnimationFrame(this.rafId);
59
+ this.rafId = null;
60
+ this.isScheduled = false;
61
+ this.isPending = true;
62
+ }
36
63
  };
64
+ // Unblock and run recompute immediately if one was pending.
37
65
  unblock = () => {
38
66
  if (this.blockDepth === 0)
39
67
  return;
@@ -43,11 +71,16 @@ export class RecomputeScheduler {
43
71
  this.onRecompute();
44
72
  }
45
73
  };
74
+ // Cancel any scheduled recompute and clear pending state.
46
75
  cancel = () => {
47
76
  if (this.timeoutId) {
48
77
  clearTimeout(this.timeoutId);
49
78
  this.timeoutId = null;
50
79
  }
80
+ if (this.rafId !== null) {
81
+ cancelAnimationFrame(this.rafId);
82
+ this.rafId = null;
83
+ }
51
84
  this.isScheduled = false;
52
85
  this.isPending = false;
53
86
  };
@@ -29,4 +29,4 @@
29
29
  *
30
30
  * @see https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
31
31
  */
32
- export declare function createRafScheduler(): (fn: () => void) => void;
32
+ export declare const createRafScheduler: () => ((_fn: () => void) => void);
package/dist/utils/raf.js CHANGED
@@ -29,11 +29,11 @@
29
29
  *
30
30
  * @see https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
31
31
  */
32
- export function createRafScheduler() {
32
+ export const createRafScheduler = () => {
33
33
  let scheduled = false;
34
34
  let callback = null;
35
- return (fn) => {
36
- callback = fn;
35
+ return (_fn) => {
36
+ callback = _fn;
37
37
  if (!scheduled) {
38
38
  scheduled = true;
39
39
  requestAnimationFrame(() => {
@@ -45,4 +45,4 @@ export function createRafScheduler() {
45
45
  });
46
46
  }
47
47
  };
48
- }
48
+ };
@@ -28,13 +28,13 @@ import type { SvelteVirtualListDebugInfo } from '../types.js';
28
28
  * 120
29
29
  * );
30
30
  */
31
- export declare function shouldShowDebugInfo(prevRange: {
31
+ export declare const shouldShowDebugInfo: (prevRange: {
32
32
  start: number;
33
33
  end: number;
34
34
  } | null, currentRange: {
35
35
  start: number;
36
36
  end: number;
37
- }, prevHeight: number, currentHeight: number): boolean;
37
+ }, prevHeight: number, currentHeight: number) => boolean;
38
38
  /**
39
39
  * Creates a comprehensive debug information object for virtual list state analysis.
40
40
  *
@@ -71,7 +71,7 @@ export declare function shouldShowDebugInfo(prevRange: {
71
71
  *
72
72
  * @throws {Error} Will throw if end index is less than start index in visibleRange
73
73
  */
74
- export declare function createDebugInfo(visibleRange: {
74
+ export declare const createDebugInfo: (visibleRange: {
75
75
  start: number;
76
76
  end: number;
77
- }, totalItems: number, processedItems: number, averageItemHeight: number, scrollTop: number, viewportHeight: number, totalHeight: number): SvelteVirtualListDebugInfo;
77
+ }, totalItems: number, processedItems: number, averageItemHeight: number, scrollTop: number, viewportHeight: number, totalHeight: number) => SvelteVirtualListDebugInfo;
@@ -27,13 +27,13 @@
27
27
  * 120
28
28
  * );
29
29
  */
30
- export function shouldShowDebugInfo(prevRange, currentRange, prevHeight, currentHeight) {
30
+ export const shouldShowDebugInfo = (prevRange, currentRange, prevHeight, currentHeight) => {
31
31
  if (!prevRange)
32
32
  return true;
33
33
  return (prevRange.start !== currentRange.start ||
34
34
  prevRange.end !== currentRange.end ||
35
35
  prevHeight !== currentHeight);
36
- }
36
+ };
37
37
  /**
38
38
  * Creates a comprehensive debug information object for virtual list state analysis.
39
39
  *
@@ -70,7 +70,7 @@ export function shouldShowDebugInfo(prevRange, currentRange, prevHeight, current
70
70
  *
71
71
  * @throws {Error} Will throw if end index is less than start index in visibleRange
72
72
  */
73
- export function createDebugInfo(visibleRange, totalItems, processedItems, averageItemHeight, scrollTop, viewportHeight, totalHeight) {
73
+ export const createDebugInfo = (visibleRange, totalItems, processedItems, averageItemHeight, scrollTop, viewportHeight, totalHeight) => {
74
74
  const atTop = scrollTop <= 1; // Small tolerance for floating point precision
75
75
  const atBottom = scrollTop >= totalHeight - viewportHeight - 1; // Small tolerance
76
76
  return {
@@ -84,4 +84,4 @@ export function createDebugInfo(visibleRange, totalItems, processedItems, averag
84
84
  atBottom,
85
85
  totalHeight
86
86
  };
87
- }
87
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-virtual-list",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "A lightweight, high-performance virtual list component for Svelte 5 that renders large datasets with minimal memory usage. Features include dynamic height support, smooth scrolling, TypeScript support, and efficient DOM recycling. Ideal for infinite scrolling lists, data tables, chat interfaces, and any application requiring the rendering of thousands of items without compromising performance. Zero dependencies and fully customizable.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -60,23 +60,23 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@eslint/compat": "^1.4.0",
63
- "@eslint/js": "^9.36.0",
63
+ "@eslint/js": "^9.37.0",
64
64
  "@faker-js/faker": "^10.0.0",
65
- "@playwright/test": "^1.55.1",
66
- "@sveltejs/adapter-auto": "^6.1.0",
67
- "@sveltejs/kit": "^2.43.4",
65
+ "@playwright/test": "^1.56.0",
66
+ "@sveltejs/adapter-auto": "^6.1.1",
67
+ "@sveltejs/kit": "^2.46.4",
68
68
  "@sveltejs/package": "^2.5.4",
69
69
  "@sveltejs/vite-plugin-svelte": "^6.2.1",
70
- "@tailwindcss/vite": "^4.1.13",
71
- "@testing-library/jest-dom": "^6.8.0",
70
+ "@tailwindcss/vite": "^4.1.14",
71
+ "@testing-library/jest-dom": "^6.9.1",
72
72
  "@testing-library/svelte": "^5.2.8",
73
73
  "@testing-library/user-event": "^14.6.1",
74
- "@types/node": "^24.5.2",
75
- "@typescript-eslint/eslint-plugin": "^8.44.1",
76
- "@typescript-eslint/parser": "^8.44.1",
74
+ "@types/node": "^24.7.1",
75
+ "@typescript-eslint/eslint-plugin": "^8.46.0",
76
+ "@typescript-eslint/parser": "^8.46.0",
77
77
  "@vitest/coverage-v8": "^3.2.4",
78
78
  "concurrently": "^9.2.1",
79
- "eslint": "^9.36.0",
79
+ "eslint": "^9.37.0",
80
80
  "eslint-config-prettier": "^10.1.8",
81
81
  "eslint-plugin-import": "^2.32.0",
82
82
  "eslint-plugin-svelte": "^3.12.4",
@@ -89,14 +89,14 @@
89
89
  "prettier-plugin-sort-json": "^4.1.1",
90
90
  "prettier-plugin-svelte": "^3.4.0",
91
91
  "prettier-plugin-tailwindcss": "^0.6.14",
92
- "publint": "^0.3.13",
93
- "svelte": "^5.39.6",
94
- "svelte-check": "^4.3.2",
95
- "tailwindcss": "^4.1.13",
92
+ "publint": "^0.3.14",
93
+ "svelte": "^5.39.11",
94
+ "svelte-check": "^4.3.3",
95
+ "tailwindcss": "^4.1.14",
96
96
  "tw-animate-css": "^1.4.0",
97
- "typescript": "^5.9.2",
98
- "typescript-eslint": "^8.44.1",
99
- "vite": "^7.1.7",
97
+ "typescript": "^5.9.3",
98
+ "typescript-eslint": "^8.46.0",
99
+ "vite": "^7.1.9",
100
100
  "vitest": "^3.2.4"
101
101
  },
102
102
  "peerDependencies": {
@@ -123,7 +123,7 @@
123
123
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
124
124
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
125
125
  "dev": "vite dev",
126
- "dev:all": "concurrently -k -n pkg,docs -c green,cyan \"pnpm -w -r --filter @humanspeak/svelte-virtual-list run dev:pkg\" \"pnpm --filter docs run dev\"",
126
+ "dev:all": "concurrently -k -n pkg,docs,sitemap -c green,cyan,magenta \"pnpm -w -r --filter @humanspeak/svelte-virtual-list run dev:pkg\" \"pnpm --filter docs run dev\" \"pnpm --filter docs run sitemap:watch\"",
127
127
  "dev:pkg": "svelte-kit sync && svelte-package --watch",
128
128
  "format": "prettier --write .",
129
129
  "lint": "prettier --check . && eslint .",
@@ -134,6 +134,7 @@
134
134
  "test:all": "pnpm run test && pnpm run test:e2e",
135
135
  "test:e2e": "playwright test",
136
136
  "test:e2e:debug": "playwright test --debug",
137
+ "test:e2e:ff": "playwright test --project=firefox",
137
138
  "test:e2e:report": "playwright show-report",
138
139
  "test:e2e:ui": "playwright test --ui",
139
140
  "test:only": "vitest run --",