@quenty/throttle 10.12.0 → 10.12.2

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/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [10.12.2](https://github.com/Quenty/NevermoreEngine/compare/@quenty/throttle@10.12.1...@quenty/throttle@10.12.2) (2026-04-30)
7
+
8
+ **Note:** Version bump only for package @quenty/throttle
9
+
10
+
11
+
12
+
13
+
14
+ ## [10.12.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/throttle@10.12.0...@quenty/throttle@10.12.1) (2026-04-29)
15
+
16
+ **Note:** Version bump only for package @quenty/throttle
17
+
18
+
19
+
20
+
21
+
6
22
  # [10.12.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/throttle@10.11.0...@quenty/throttle@10.12.0) (2026-04-23)
7
23
 
8
24
  **Note:** Version bump only for package @quenty/throttle
@@ -0,0 +1,10 @@
1
+ {
2
+ "targets": {
3
+ "test": {
4
+ "universeId": 9716264427,
5
+ "placeId": 120455070031007,
6
+ "project": "test/default.project.json",
7
+ "scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/throttle",
3
- "version": "10.12.0",
3
+ "version": "10.12.2",
4
4
  "description": "Adds the throttle function to Roblox",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -30,7 +30,9 @@
30
30
  ],
31
31
  "dependencies": {
32
32
  "@quenty/loader": "10.11.0",
33
- "@quenty/typeutils": "1.3.0"
33
+ "@quenty/nevermore-test-runner": "1.4.0",
34
+ "@quenty/typeutils": "1.3.0",
35
+ "@quentystudios/jest-lua": "3.10.0-quenty.2"
34
36
  },
35
37
  "devDependencies": {
36
38
  "@quenty/loader": "workspace:*"
@@ -38,5 +40,5 @@
38
40
  "publishConfig": {
39
41
  "access": "public"
40
42
  },
41
- "gitHead": "9413391da8b6f9026762285b601fd1d37d385a54"
43
+ "gitHead": "7672b52f13af6a10df1b189d19d6e2404b5b3e55"
42
44
  }
@@ -1,9 +1,16 @@
1
1
  --!strict
2
2
  --[=[
3
- Throttles execution of a functon. Does both leading, and following
3
+ Throttles execution of a function with configurable leading and trailing behavior.
4
4
  @class ThrottledFunction
5
5
  ]=]
6
6
 
7
+ --[=[
8
+ @interface ThrottleConfig
9
+ .leading boolean? -- If true, will dispatch immediately after creating this ThrottledFunction.
10
+ .trailing boolean? -- If true, will dispatch after the timeout with the latest-called args.
11
+ .leadingFirstTimeOnly boolean? -- If true, will dispatch immediately after creating this ThrottledFunction, but from then on, will begin the <timeout> window upon manual call and delay dispatch until <timeout> seconds have passed (with latest-called args).
12
+ @within ThrottledFunction
13
+ ]=]
7
14
  export type ThrottleConfig = {
8
15
  leading: boolean?,
9
16
  trailing: boolean?,
@@ -25,14 +32,23 @@ export type ThrottledFunction<T...> = typeof(setmetatable(
25
32
  _callLeading: boolean,
26
33
  _callTrailing: boolean,
27
34
  _callLeadingFirstTime: boolean?,
35
+ _delayedDispatchThread: thread?,
28
36
  },
29
37
  {} :: typeof({ __index = ThrottledFunction })
30
38
  ))
31
39
 
40
+ --[=[
41
+ @function new
42
+ @within ThrottledFunction
43
+ @param timeoutInSeconds number -- The (minimum) time in seconds to wait between each function dispatch; the "cooldown".
44
+ @param func function -- The actual function whose calls will be throttled.
45
+ @param config? ThrottleConfig -- The configuration for how throttling will behave.
46
+ @return ThrottledFunction<T...>
47
+ ]=]
32
48
  function ThrottledFunction.new<T...>(
33
49
  timeoutInSeconds: number,
34
50
  func: Func<T...>,
35
- config: ThrottleConfig
51
+ config: ThrottleConfig?
36
52
  ): ThrottledFunction<T...>
37
53
  local self: ThrottledFunction<T...> = setmetatable({} :: any, ThrottledFunction)
38
54
 
@@ -44,51 +60,84 @@ function ThrottledFunction.new<T...>(
44
60
 
45
61
  self._callLeading = true
46
62
  self._callTrailing = true
63
+ self._delayedDispatchThread = nil
47
64
 
48
- self:_configureOrError(config)
65
+ self:_configureOrError(config or {
66
+ leading = true,
67
+ trailing = true,
68
+ })
49
69
 
50
70
  return self
51
71
  end
52
72
 
73
+ --[=[
74
+ If leading = true, will enable Call() dispatching immediately after creating this ThrottledFunction.
75
+ Else, will have to wait <timeout> seconds before it dispatches with the latest-called args.
76
+
77
+ If trailing = true, will dispatch after the timeout with the latest-called args.
78
+ Else, will not automatically dispatch, and must manually call again after <timeout> seconds.
79
+
80
+ If leadingFirstTimeOnly = true, will enable Call() dispatching immediately after creating this
81
+ ThrottledFunction, but from then on, will begin the <timeout> window upon manual call
82
+ and delay dispatch until <timeout> seconds have passed (with latest-called args).
83
+
84
+ @function Call
85
+ @within ThrottledFunction
86
+ ]=]
53
87
  function ThrottledFunction.Call<T...>(self: ThrottledFunction<T...>, ...: T...)
88
+ local now = os.clock()
89
+
54
90
  if self._trailingValue then
55
- -- Update the next value to be dispatched
91
+ -- If it's not nil, we're likely in the middle of the cooldown window
92
+ -- so all we can do is update the trailing value, waiting for the delayed dispatch to reset it to nil.
56
93
  self._trailingValue = table.pack(...)
57
- elseif self._nextCallTimeStamp <= tick() then
94
+ return
95
+ end
96
+
97
+ if self._nextCallTimeStamp <= now then
98
+ -- We're outside the cooldown window
58
99
  if self._callLeading or self._callLeadingFirstTime then
59
- self._callLeadingFirstTime = false
60
100
  -- Dispatch immediately
61
- self._nextCallTimeStamp = tick() + self._timeout
101
+ self._callLeadingFirstTime = false
102
+ self._nextCallTimeStamp = now + self._timeout
62
103
  self._func(...)
63
104
  elseif self._callTrailing then
64
- -- Schedule for trailing at exactly timeout
65
- self._trailingValue = table.pack(...)
66
- task.delay(self._timeout, function()
67
- if self.Destroy then
68
- self:_dispatch()
69
- end
70
- end)
105
+ -- Leading is disabled, but trailing is enabled; schedule for trailing.
106
+ self:_scheduleTrailing(self._timeout, ...)
71
107
  else
72
- error("[ThrottledFunction.Cleanup] - Trailing and leading are both disabled")
108
+ error("[ThrottledFunction.Call] - Trailing and leading are both disabled")
73
109
  end
74
- elseif self._callLeading or self._callTrailing or self._callLeadingFirstTime then
75
- self._callLeadingFirstTime = false
76
- -- As long as either leading or trailing are set to true, we are good
77
- local remainingTime = self._nextCallTimeStamp - tick()
78
- self._trailingValue = table.pack(...)
110
+ return
111
+ end
79
112
 
80
- task.delay(remainingTime, function()
81
- if self.Destroy then
82
- self:_dispatch()
83
- end
84
- end)
113
+ if self._callTrailing then
114
+ -- We have no trailing value; it was dispatched a bit ago, or we just created this ThrottledFunction.
115
+ -- We're inside the cooldown window, so it's not dispatched/created that far ago. (we can't dispatch immediately.)
116
+ -- We should supply a trailing value, without immediately dispatching.
117
+ self._callLeadingFirstTime = false
118
+ local remainingTime = math.max(0, self._nextCallTimeStamp - now)
119
+ self:_scheduleTrailing(remainingTime, ...)
85
120
  end
121
+ -- But if we don't have trailing, best to ignore the call (the args are dropped.)
86
122
  end
87
123
 
88
124
  ThrottledFunction.__call = ThrottledFunction.Call
89
125
 
126
+ function ThrottledFunction._scheduleTrailing<T...>(self: ThrottledFunction<T...>, delayTime: number, ...: T...)
127
+ self._trailingValue = table.pack(...)
128
+ if self._delayedDispatchThread then
129
+ task.cancel(self._delayedDispatchThread)
130
+ end
131
+ self._delayedDispatchThread = task.delay(delayTime, function()
132
+ if self.Destroy then
133
+ self:_dispatch()
134
+ end
135
+ end)
136
+ end
137
+
90
138
  function ThrottledFunction._dispatch<T...>(self: ThrottledFunction<T...>)
91
- self._nextCallTimeStamp = tick() + self._timeout
139
+ self._nextCallTimeStamp = os.clock() + self._timeout
140
+ self._delayedDispatchThread = nil
92
141
 
93
142
  local trailingValue = self._trailingValue
94
143
  if trailingValue then
@@ -122,10 +171,20 @@ function ThrottledFunction._configureOrError<T...>(self: ThrottledFunction<T...>
122
171
  assert(self._callLeading or self._callTrailing, "Cannot configure both leading and trailing disabled")
123
172
  end
124
173
 
174
+ --[=[
175
+ Cancels any pending trailing calls.
176
+
177
+ @function Destroy
178
+ @within ThrottledFunction
179
+ ]=]
125
180
  function ThrottledFunction.Destroy<T...>(self: ThrottledFunction<T...>)
126
181
  local private: any = self
127
182
  private._trailingValue = nil
128
183
  private._func = nil
184
+ if private._delayedDispatchThread then
185
+ task.cancel(private._delayedDispatchThread)
186
+ end
187
+ private._delayedDispatchThread = nil
129
188
  setmetatable(private, nil)
130
189
  end
131
190
 
@@ -0,0 +1,151 @@
1
+ --!strict
2
+ --[[
3
+ @class ThrottledFunction.spec.lua
4
+ ]]
5
+
6
+ local require = (require :: any)(
7
+ game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent
8
+ ).bootstrapStory(script) :: typeof(require(script.Parent.loader).load(script))
9
+
10
+ local Jest = require("Jest")
11
+ local ThrottledFunction = require("ThrottledFunction")
12
+
13
+ local describe = Jest.Globals.describe
14
+ local expect = Jest.Globals.expect
15
+ local it = Jest.Globals.it
16
+ local jest = Jest.Globals.jest
17
+
18
+ local TIMEOUT = 1
19
+ local TIMEOUT_MS = TIMEOUT * 1000
20
+
21
+ local function recordCalls<T...>()
22
+ local calls = {}
23
+
24
+ local function callback(...: T...)
25
+ table.insert(calls, table.pack(...))
26
+ end
27
+
28
+ return calls, callback
29
+ end
30
+
31
+ describe("ThrottledFunction", function()
32
+ it("should drop cooldown calls when trailing is disabled", function()
33
+ jest.useFakeTimers()
34
+
35
+ local calls, callback = recordCalls()
36
+ local throttled = ThrottledFunction.new(TIMEOUT, callback, {
37
+ leading = true,
38
+ trailing = false,
39
+ })
40
+
41
+ throttled:Call("first")
42
+ throttled:Call("too fast, drop me")
43
+
44
+ jest.advanceTimersByTime(TIMEOUT_MS)
45
+
46
+ expect(#calls).toEqual(1)
47
+ expect(calls[1][1]).toEqual("first")
48
+
49
+ throttled:Destroy()
50
+ jest.useRealTimers()
51
+ end)
52
+
53
+ it("should dispatch the latest trailing call with all arguments", function()
54
+ jest.useFakeTimers()
55
+
56
+ local calls, callback = recordCalls()
57
+ local throttled = ThrottledFunction.new(TIMEOUT, callback, {
58
+ leading = true,
59
+ trailing = true,
60
+ })
61
+
62
+ throttled:Call("first")
63
+ throttled:Call("second but will be overwritten by the next call...")
64
+ throttled:Call("third", nil, "fourth")
65
+ jest.advanceTimersByTime(TIMEOUT_MS)
66
+
67
+ expect(#calls).toEqual(2)
68
+ expect(calls[1][1]).toEqual("first")
69
+ expect(calls[2].n).toEqual(3)
70
+ expect(calls[2][1]).toEqual("third")
71
+ expect(calls[2][2]).toEqual(nil)
72
+ expect(calls[2][3]).toEqual("fourth")
73
+
74
+ throttled:Destroy()
75
+ jest.useRealTimers()
76
+ end)
77
+
78
+ it("should delay trailing-only calls and keep the latest arguments", function()
79
+ jest.useFakeTimers()
80
+
81
+ local calls, callback = recordCalls()
82
+ local throttled = ThrottledFunction.new(TIMEOUT, callback, {
83
+ leading = false,
84
+ trailing = true,
85
+ })
86
+
87
+ throttled:Call("first but will be overwritten by the next call...")
88
+ throttled:Call("second, final and dispatched")
89
+
90
+ expect(#calls).toEqual(0)
91
+
92
+ jest.advanceTimersByTime(TIMEOUT_MS)
93
+
94
+ expect(#calls).toEqual(1)
95
+ expect(calls[1][1]).toEqual("second, final and dispatched")
96
+
97
+ throttled:Destroy()
98
+ jest.useRealTimers()
99
+ end)
100
+
101
+ it("should cancel pending trailing calls when destroyed, not calling after destroyed", function()
102
+ jest.useFakeTimers()
103
+
104
+ local calls, callback = recordCalls()
105
+ local throttled = ThrottledFunction.new(TIMEOUT, callback, {
106
+ leading = false,
107
+ trailing = true,
108
+ })
109
+
110
+ throttled:Call("first but will be destroyed and thus discarded")
111
+ throttled:Destroy()
112
+ jest.advanceTimersByTime(TIMEOUT_MS)
113
+
114
+ expect(#calls).toEqual(0)
115
+
116
+ jest.useRealTimers()
117
+ end)
118
+
119
+ it("should reject if leading and trailing are both false", function()
120
+ local _, callback = recordCalls()
121
+
122
+ expect(function()
123
+ ThrottledFunction.new(TIMEOUT, callback, {
124
+ leading = false,
125
+ trailing = false,
126
+ })
127
+ end).toThrow()
128
+
129
+ expect(function()
130
+ ThrottledFunction.new(
131
+ TIMEOUT,
132
+ callback,
133
+ {
134
+ leading = true,
135
+ trailing = true,
136
+ notAConfigKey = true,
137
+ } :: any
138
+ )
139
+ end).toThrow()
140
+
141
+ expect(function()
142
+ ThrottledFunction.new(
143
+ TIMEOUT,
144
+ callback,
145
+ {
146
+ leading = "yes",
147
+ } :: any
148
+ )
149
+ end).toThrow()
150
+ end)
151
+ end)
@@ -0,0 +1,3 @@
1
+ return {
2
+ testMatch = { "**/*.spec" },
3
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "ThrottleTest",
3
+ "tree": {
4
+ "$className": "DataModel",
5
+ "ServerScriptService": {
6
+ "$properties": {
7
+ "LoadStringEnabled": true
8
+ },
9
+ "throttle": {
10
+ "$path": ".."
11
+ },
12
+ "Script": {
13
+ "$path": "scripts/Server"
14
+ }
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,12 @@
1
+ --!nonstrict
2
+ local ServerScriptService = game:GetService("ServerScriptService")
3
+
4
+ local root = ServerScriptService.throttle
5
+ local loader = root:FindFirstChild("LoaderUtils", true).Parent
6
+ local require = require(loader).bootstrapGame(root)
7
+
8
+ local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
9
+
10
+ if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
11
+ return
12
+ end