@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 +8 -0
- package/deploy.nevermore.json +10 -0
- package/package.json +5 -3
- package/src/Shared/ThrottledFunction.lua +86 -26
- package/src/Shared/ThrottledFunction.spec.lua +151 -0
- package/src/jest.config.lua +3 -0
- package/test/default.project.json +17 -0
- package/test/scripts/Server/ServerMain.server.lua +12 -0
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/throttle",
|
|
3
|
-
"version": "10.12.
|
|
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/
|
|
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": "
|
|
43
|
+
"gitHead": "6801a02bc7e7de951df4b9f65c0efc27b642db70"
|
|
42
44
|
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
--!strict
|
|
2
2
|
--[=[
|
|
3
|
-
Throttles execution of a
|
|
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
|
-
--
|
|
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
|
-
|
|
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.
|
|
102
|
+
self._callLeadingFirstTime = false
|
|
103
|
+
self._nextCallTimeStamp = now + self._timeout
|
|
62
104
|
self._func(...)
|
|
63
105
|
elseif self._callTrailing then
|
|
64
|
-
--
|
|
65
|
-
self.
|
|
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.
|
|
109
|
+
error("[ThrottledFunction.Call] - Trailing and leading are both disabled")
|
|
73
110
|
end
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 =
|
|
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,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
|