@humanspeak/svelte-virtual-list 0.3.5 → 0.3.6

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.
@@ -537,6 +537,8 @@
537
537
  let programmaticScrollInProgress = $state(false) // Prevent bottom-anchoring during programmatic scrolls
538
538
  let lastCalculatedHeight = $state(0)
539
539
  let lastItemsLength = $state(0)
540
+ // Track last observed total height to compute precise deltas on item count changes
541
+ let lastTotalHeightObserved = $state(0)
540
542
 
541
543
  /**
542
544
  * CRITICAL: O(1) Reactive Total Height Calculation
@@ -661,34 +663,96 @@
661
663
  const currentCalculatedItemHeight = heightManager.averageHeight
662
664
  const currentHeight = height
663
665
  const currentTotalHeight = totalHeight()
664
- const maxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
666
+ const prevTotalHeight =
667
+ lastTotalHeightObserved ||
668
+ currentTotalHeight - itemsAdded * currentCalculatedItemHeight
669
+ const prevMaxScrollTop = Math.max(0, prevTotalHeight - currentHeight)
670
+ const nextMaxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
671
+ const deltaMax = nextMaxScrollTop - prevMaxScrollTop
672
+ log('[SVL] items-length-change:before', {
673
+ instanceId,
674
+ itemsAdded,
675
+ lastItemsLength,
676
+ currentItemsLength,
677
+ currentScrollTop,
678
+ prevTotalHeight,
679
+ currentTotalHeight,
680
+ prevMaxScrollTop,
681
+ nextMaxScrollTop,
682
+ deltaMax,
683
+ averageItemHeight: currentCalculatedItemHeight
684
+ })
685
+
686
+ // Maintain visual position for ALL cases by advancing scrollTop by deltaMax.
687
+ // If near the bottom, this naturally pins to the new max; otherwise it preserves the current content.
688
+ programmaticScrollInProgress = true
689
+ void heightManager.runDynamicUpdate(() => {
690
+ const unclamped = currentScrollTop + deltaMax
691
+ const newScrollTop = Math.max(0, Math.min(nextMaxScrollTop, unclamped))
692
+ heightManager.viewport.scrollTop = newScrollTop
693
+ heightManager.scrollTop = newScrollTop
694
+ log('[SVL] items-length-change:applied', {
695
+ instanceId,
696
+ previousScrollTop: currentScrollTop,
697
+ appliedScrollTop: newScrollTop,
698
+ prevMaxScrollTop,
699
+ nextMaxScrollTop,
700
+ deltaMax
701
+ })
702
+
703
+ // We are explicitly managing position; consider this a programmatic action.
704
+ // Do not flip userHasScrolledAway here; it should reflect user intent only.
665
705
 
666
- // Check if user was at/near the bottom before items were added
667
- const wasNearBottom =
668
- Math.abs(
669
- currentScrollTop -
670
- Math.max(
706
+ // Reconcile on next frame in case measured heights adjust totals
707
+ requestAnimationFrame(() => {
708
+ const beforeReconcileScrollTop = heightManager.viewport.scrollTop
709
+ const reconciledNextMax = Math.max(0, totalHeight() - height)
710
+ const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
711
+ // Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
712
+ const desiredScrollTop = Math.max(
713
+ 0,
714
+ Math.min(reconciledNextMax, newScrollTop + reconciledDeltaMaxChange)
715
+ )
716
+ // Snap to integer pixels to prevent oscillation due to subpixel rounding
717
+ const desiredRounded = Math.round(desiredScrollTop)
718
+ const diffToDesired = desiredRounded - heightManager.viewport.scrollTop
719
+ if (Math.abs(diffToDesired) >= 1) {
720
+ const adjusted = Math.max(
671
721
  0,
672
- lastItemsLength * currentCalculatedItemHeight - currentHeight
722
+ Math.min(reconciledNextMax, desiredRounded)
673
723
  )
674
- ) <
675
- currentCalculatedItemHeight * 2
676
-
677
- if (wasNearBottom || currentScrollTop === 0) {
678
- // User was at bottom, keep them at bottom after new items are added
679
- void heightManager.runDynamicUpdate(() => {
680
- const newScrollTop = maxScrollTop
681
- heightManager.viewport.scrollTop = newScrollTop
682
- heightManager.scrollTop = newScrollTop
683
-
684
- // Reset the "scrolled away" flag since we're actively managing position
685
- userHasScrolledAway = false
724
+ heightManager.viewport.scrollTop = adjusted
725
+ heightManager.scrollTop = adjusted
726
+ log('[SVL] items-length-change:reconciled', {
727
+ instanceId,
728
+ beforeReconcileScrollTop,
729
+ adjustedScrollTop: adjusted,
730
+ reconciledNextMax,
731
+ reconciledDeltaMaxChange,
732
+ desiredScrollTop,
733
+ desiredRounded,
734
+ diffToDesired
735
+ })
736
+ } else {
737
+ log('[SVL] items-length-change:reconciled-skip', {
738
+ instanceId,
739
+ beforeReconcileScrollTop,
740
+ reconciledNextMax,
741
+ reconciledDeltaMaxChange,
742
+ desiredScrollTop,
743
+ desiredRounded,
744
+ diffToDesired
745
+ })
746
+ }
747
+ programmaticScrollInProgress = false
686
748
  })
687
- }
749
+ })
688
750
  }
689
751
  }
690
752
 
691
753
  lastItemsLength = currentItemsLength
754
+ // Update last observed total height at the end of the effect
755
+ lastTotalHeightObserved = totalHeight()
692
756
  })
693
757
 
694
758
  // Update container height continuously to reflect layout changes that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-virtual-list",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",
@@ -59,45 +59,45 @@
59
59
  "esm-env": "^1.2.2"
60
60
  },
61
61
  "devDependencies": {
62
- "@eslint/compat": "^1.4.0",
63
- "@eslint/js": "^9.37.0",
64
- "@faker-js/faker": "^10.0.0",
65
- "@playwright/test": "^1.56.0",
66
- "@sveltejs/adapter-auto": "^6.1.1",
67
- "@sveltejs/kit": "^2.46.4",
62
+ "@eslint/compat": "^1.4.1",
63
+ "@eslint/js": "^9.39.1",
64
+ "@faker-js/faker": "^10.1.0",
65
+ "@playwright/test": "^1.56.1",
66
+ "@sveltejs/adapter-auto": "^7.0.0",
67
+ "@sveltejs/kit": "^2.48.4",
68
68
  "@sveltejs/package": "^2.5.4",
69
69
  "@sveltejs/vite-plugin-svelte": "^6.2.1",
70
- "@tailwindcss/vite": "^4.1.14",
70
+ "@tailwindcss/vite": "^4.1.17",
71
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.7.1",
75
- "@typescript-eslint/eslint-plugin": "^8.46.0",
76
- "@typescript-eslint/parser": "^8.46.0",
77
- "@vitest/coverage-v8": "^3.2.4",
74
+ "@types/node": "^24.10.1",
75
+ "@typescript-eslint/eslint-plugin": "^8.46.4",
76
+ "@typescript-eslint/parser": "^8.46.4",
77
+ "@vitest/coverage-v8": "^4.0.8",
78
78
  "concurrently": "^9.2.1",
79
- "eslint": "^9.37.0",
79
+ "eslint": "^9.39.1",
80
80
  "eslint-config-prettier": "^10.1.8",
81
81
  "eslint-plugin-import": "^2.32.0",
82
- "eslint-plugin-svelte": "^3.12.4",
83
- "eslint-plugin-unused-imports": "^4.2.0",
84
- "globals": "^16.4.0",
82
+ "eslint-plugin-svelte": "^3.13.0",
83
+ "eslint-plugin-unused-imports": "^4.3.0",
84
+ "globals": "^16.5.0",
85
85
  "husky": "^9.1.7",
86
- "jsdom": "^27.0.0",
86
+ "jsdom": "^27.2.0",
87
87
  "prettier": "^3.6.2",
88
88
  "prettier-plugin-organize-imports": "^4.3.0",
89
89
  "prettier-plugin-sort-json": "^4.1.1",
90
90
  "prettier-plugin-svelte": "^3.4.0",
91
- "prettier-plugin-tailwindcss": "^0.6.14",
92
- "publint": "^0.3.14",
93
- "svelte": "^5.39.11",
94
- "svelte-check": "^4.3.3",
95
- "tailwindcss": "^4.1.14",
91
+ "prettier-plugin-tailwindcss": "^0.7.1",
92
+ "publint": "^0.3.15",
93
+ "svelte": "^5.43.6",
94
+ "svelte-check": "^4.3.4",
95
+ "tailwindcss": "^4.1.17",
96
96
  "tw-animate-css": "^1.4.0",
97
97
  "typescript": "^5.9.3",
98
- "typescript-eslint": "^8.46.0",
99
- "vite": "^7.1.9",
100
- "vitest": "^3.2.4"
98
+ "typescript-eslint": "^8.46.4",
99
+ "vite": "^7.2.2",
100
+ "vitest": "^4.0.8"
101
101
  },
102
102
  "peerDependencies": {
103
103
  "svelte": "^5.0.0"
@@ -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,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\"",
126
+ "dev:all": "concurrently -k -n pkg,docs,sitemap -c green,cyan,magenta \"pnpm -w -r --filter @humanspeak/svelte-virtual-list run dev\" \"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 .",
@@ -138,6 +138,7 @@
138
138
  "test:e2e:report": "playwright show-report",
139
139
  "test:e2e:ui": "playwright test --ui",
140
140
  "test:only": "vitest run --",
141
+ "test:unit": "vitest run --coverage",
141
142
  "test:watch": "vitest --"
142
143
  }
143
144
  }