@quenty/throttle 10.12.0 → 10.12.1

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,14 @@
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.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/throttle@10.12.0...@quenty/throttle@10.12.1) (2026-04-29)
7
+
8
+ **Note:** Version bump only for package @quenty/throttle
9
+
10
+
11
+
12
+
13
+
6
14
  # [10.12.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/throttle@10.11.0...@quenty/throttle@10.12.0) (2026-04-23)
7
15
 
8
16
  **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.1",
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": "6801a02bc7e7de951df4b9f65c0efc27b642db70"
42
44
  }
@@ -1,9 +1,17 @@
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
12
+ and delay dispatch until <timeout> seconds have passed (with latest-called args).
13
+ @within ThrottledFunction
14
+ ]=]
7
15
  export type ThrottleConfig = {
8
16
  leading: boolean?,
9
17
  trailing: boolean?,
@@ -25,14 +33,23 @@ export type ThrottledFunction<T...> = typeof(setmetatable(
25
33
  _callLeading: boolean,
26
34
  _callTrailing: boolean,
27
35
  _callLeadingFirstTime: boolean?,
36
+ _delayedDispatchThread: thread?,
28
37
  },
29
38
  {} :: typeof({ __index = ThrottledFunction })
30
39
  ))
31
40
 
41
+ --[=[
42
+ @function new
43
+ @within ThrottledFunction
44
+ @param timeoutInSeconds number -- The (minimum) time in seconds to wait between each function dispatch; the "cooldown".
45
+ @param func function -- The actual function whose calls will be throttled.
46
+ @param config? ThrottleConfig -- The configuration for how throttling will behave.
47
+ @return ThrottledFunction<T...>
48
+ ]=]
32
49
  function ThrottledFunction.new<T...>(
33
50
  timeoutInSeconds: number,
34
51
  func: Func<T...>,
35
- config: ThrottleConfig
52
+ config: ThrottleConfig?
36
53
  ): ThrottledFunction<T...>
37
54
  local self: ThrottledFunction<T...> = setmetatable({} :: any, ThrottledFunction)
38
55
 
@@ -44,51 +61,84 @@ function ThrottledFunction.new<T...>(
44
61
 
45
62
  self._callLeading = true
46
63
  self._callTrailing = true
64
+ self._delayedDispatchThread = nil
47
65
 
48
- self:_configureOrError(config)
66
+ self:_configureOrError(config or {
67
+ leading = true,
68
+ trailing = true,
69
+ })
49
70
 
50
71
  return self
51
72
  end
52
73
 
74
+ --[=[
75
+ If leading = true, will enable Call() dispatching immediately after creating this ThrottledFunction.
76
+ Else, will have to wait <timeout> seconds before it dispatches with the latest-called args.
77
+
78
+ If trailing = true, will dispatch after the timeout with the latest-called args.
79
+ Else, will not automatically dispatch, and must manually call again after <timeout> seconds.
80
+
81
+ If leadingFirstTimeOnly = true, will enable Call() dispatching immediately after creating this
82
+ ThrottledFunction, but from then on, will begin the <timeout> window upon manual call
83
+ and delay dispatch until <timeout> seconds have passed (with latest-called args).
84
+
85
+ @function Call
86
+ @within ThrottledFunction
87
+ ]=]
53
88
  function ThrottledFunction.Call<T...>(self: ThrottledFunction<T...>, ...: T...)
89
+ local now = os.clock()
90
+
54
91
  if self._trailingValue then
55
- -- Update the next value to be dispatched
92
+ -- If it's not nil, we're likely in the middle of the cooldown window
93
+ -- so all we can do is update the trailing value, waiting for the delayed dispatch to reset it to nil.
56
94
  self._trailingValue = table.pack(...)
57
- elseif self._nextCallTimeStamp <= tick() then
95
+ return
96
+ end
97
+
98
+ if self._nextCallTimeStamp <= now then
99
+ -- We're outside the cooldown window
58
100
  if self._callLeading or self._callLeadingFirstTime then
59
- self._callLeadingFirstTime = false
60
101
  -- Dispatch immediately
61
- self._nextCallTimeStamp = tick() + self._timeout
102
+ self._callLeadingFirstTime = false
103
+ self._nextCallTimeStamp = now + self._timeout
62
104
  self._func(...)
63
105
  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)
106
+ -- Leading is disabled, but trailing is enabled; schedule for trailing.
107
+ self:_scheduleTrailing(self._timeout, ...)
71
108
  else
72
- error("[ThrottledFunction.Cleanup] - Trailing and leading are both disabled")
109
+ error("[ThrottledFunction.Call] - Trailing and leading are both disabled")
73
110
  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(...)
111
+ return
112
+ end
79
113
 
80
- task.delay(remainingTime, function()
81
- if self.Destroy then
82
- self:_dispatch()
83
- end
84
- end)
114
+ if self._callTrailing then
115
+ -- We have no trailing value; it was dispatched a bit ago, or we just created this ThrottledFunction.
116
+ -- We're inside the cooldown window, so it's not dispatched/created that far ago. (we can't dispatch immediately.)
117
+ -- We should supply a trailing value, without immediately dispatching.
118
+ self._callLeadingFirstTime = false
119
+ local remainingTime = math.max(0, self._nextCallTimeStamp - now)
120
+ self:_scheduleTrailing(remainingTime, ...)
85
121
  end
122
+ -- But if we don't have trailing, best to ignore the call (the args are dropped.)
86
123
  end
87
124
 
88
125
  ThrottledFunction.__call = ThrottledFunction.Call
89
126
 
127
+ function ThrottledFunction._scheduleTrailing<T...>(self: ThrottledFunction<T...>, delayTime: number, ...: T...)
128
+ self._trailingValue = table.pack(...)
129
+ if self._delayedDispatchThread then
130
+ task.cancel(self._delayedDispatchThread)
131
+ end
132
+ self._delayedDispatchThread = task.delay(delayTime, function()
133
+ if self.Destroy then
134
+ self:_dispatch()
135
+ end
136
+ end)
137
+ end
138
+
90
139
  function ThrottledFunction._dispatch<T...>(self: ThrottledFunction<T...>)
91
- self._nextCallTimeStamp = tick() + self._timeout
140
+ self._nextCallTimeStamp = os.clock() + self._timeout
141
+ self._delayedDispatchThread = nil
92
142
 
93
143
  local trailingValue = self._trailingValue
94
144
  if trailingValue then
@@ -122,10 +172,20 @@ function ThrottledFunction._configureOrError<T...>(self: ThrottledFunction<T...>
122
172
  assert(self._callLeading or self._callTrailing, "Cannot configure both leading and trailing disabled")
123
173
  end
124
174
 
175
+ --[=[
176
+ Cancels any pending trailing calls.
177
+
178
+ @function Destroy
179
+ @within ThrottledFunction
180
+ ]=]
125
181
  function ThrottledFunction.Destroy<T...>(self: ThrottledFunction<T...>)
126
182
  local private: any = self
127
183
  private._trailingValue = nil
128
184
  private._func = nil
185
+ if private._delayedDispatchThread then
186
+ task.cancel(private._delayedDispatchThread)
187
+ end
188
+ private._delayedDispatchThread = nil
129
189
  setmetatable(private, nil)
130
190
  end
131
191
 
@@ -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