├── .gitignore ├── default.project.json ├── docs ├── intro.md └── Installation.md ├── src ├── HookConnection.lua ├── createUseFlipper.lua ├── createUsePortal.lua ├── copy.lua ├── createUseDispatch.lua ├── createUseSelector.lua ├── getRbxtsSource.lua ├── createUseStore.lua ├── Hooks │ ├── useRendersSpy.lua │ ├── useForceUpdate.lua │ ├── usePrevious.lua │ ├── useToggle.lua │ ├── useUpdateEffect.lua │ ├── useFlipper.lua │ ├── useMaid.lua │ ├── useQueue.lua │ ├── useDispatch.lua │ ├── useStore.lua │ ├── useLatest.lua │ ├── useDebouncedText.lua │ ├── useReactiveState.lua │ ├── useAsync.lua │ ├── useSelector.lua │ ├── useEvent.lua │ ├── useTimeout.lua │ ├── useTween.lua │ ├── useCounter.lua │ ├── useDebounce.lua │ ├── useArray.lua │ ├── useUndo.lua │ └── usePortal.lua ├── ts.lua ├── merge.lua ├── Symbol.lua ├── Library │ ├── Maid.lua │ ├── Roselect.lua │ └── Promise.lua ├── init.lua └── createSource.lua ├── wally.toml ├── README.md ├── typings ├── usePrevious.d.ts ├── useRendersSpy.d.ts ├── useForceUpdate.d.ts ├── useDebouncedText.d.ts ├── useReactiveState.d.ts ├── useMaid.d.ts ├── useToggle.d.ts ├── useQueue.d.ts ├── useAsync.d.ts ├── useDispatch.d.ts ├── useFlipper.d.ts ├── useLatest.d.ts ├── useStore.d.ts ├── useCounter.d.ts ├── useSelector.d.ts ├── useUpdateEffect.d.ts ├── useArray.d.ts ├── useTween.d.ts ├── useUndo.d.ts ├── usePortal.d.ts ├── useTimeout.d.ts ├── useDebounce.d.ts ├── index.d.ts └── useEvent.ts ├── aftman.toml ├── moonwave.toml ├── tsconfig.json ├── .moonwave └── custom.css ├── package.json └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | test.tsx 4 | build 5 | model.rbxm -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hook-bag", 3 | "tree": { 4 | "$path": "src" 5 | } 6 | } -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Table of Contents 6 | 7 | - [Installation](/docs/Installation) 8 | - [API Reference](/api/Hooks) -------------------------------------------------------------------------------- /src/HookConnection.lua: -------------------------------------------------------------------------------- 1 | local Symbol = require(script.Parent.Symbol) 2 | 3 | local HookConnection = Symbol.named("HookConnection") 4 | 5 | return HookConnection -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rimuy/hook-bag" 3 | version = "0.1.0" 4 | license = "MPL-2.0" 5 | authors = ["Rimuy"] 6 | registry = "https://github.com/UpliftGames/wally-index" 7 | realm = "shared" -------------------------------------------------------------------------------- /src/createUseFlipper.lua: -------------------------------------------------------------------------------- 1 | local useFlipper = require(script.Parent.Hooks.useFlipper) 2 | 3 | local function createUseFlipper(flipper) 4 | return useFlipper(require(flipper)) 5 | end 6 | 7 | return createUseFlipper -------------------------------------------------------------------------------- /src/createUsePortal.lua: -------------------------------------------------------------------------------- 1 | local usePortal = require(script.Parent.Hooks.usePortal) 2 | 3 | local function createUsePortal(roactSource) 4 | return usePortal(require(roactSource)) 5 | end 6 | 7 | return createUsePortal -------------------------------------------------------------------------------- /src/copy.lua: -------------------------------------------------------------------------------- 1 | local function copy(tbl) 2 | local newTbl = {} 3 | for k, v in pairs(tbl) do 4 | newTbl[k] = v 5 | end 6 | 7 | return newTbl 8 | end 9 | 10 | return copy -------------------------------------------------------------------------------- /src/createUseDispatch.lua: -------------------------------------------------------------------------------- 1 | local useDispatch = require(script.Parent.Hooks.useDispatch) 2 | 3 | local function createUseDispatch(useStore, useSelector) 4 | return useDispatch(useStore, useSelector) 5 | end 6 | 7 | return createUseDispatch -------------------------------------------------------------------------------- /src/createUseSelector.lua: -------------------------------------------------------------------------------- 1 | local useSelector = require(script.Parent.Hooks.useSelector) 2 | 3 | local function createUseDispatch(roselect, useStore) 4 | return useSelector(require(roselect), useStore) 5 | end 6 | 7 | return createUseDispatch -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Roact-Hooks Bag

3 |

Collection of custom hooks for Roact.

4 | View docs 5 |
6 | -------------------------------------------------------------------------------- /typings/usePrevious.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | /** 4 | * Simply returns the previous state. 5 | * @param value The state. 6 | */ 7 | declare function usePrevious( 8 | value: T 9 | ): HookCreator; 10 | 11 | export = usePrevious; -------------------------------------------------------------------------------- /aftman.toml: -------------------------------------------------------------------------------- 1 | # This file lists tools managed by Aftman, a cross-platform toolchain manager. 2 | # For more information, see https://github.com/LPGhatguy/aftman 3 | 4 | # To add a new tool, add an entry to this table. 5 | [tools] 6 | rojo = "rojo-rbx/rojo@7.2.1" 7 | wally = "UpliftGames/wally@0.3.1" -------------------------------------------------------------------------------- /typings/useRendersSpy.d.ts: -------------------------------------------------------------------------------- 1 | import { CoreHooks } from "@rbxts/roact-hooks"; 2 | 3 | /** 4 | * Returns the amount of renders the component has since its mount. 5 | * @param hooks The core hooks. 6 | */ 7 | declare function useRendersSpy(hooks: CoreHooks): number; 8 | 9 | export = useRendersSpy; -------------------------------------------------------------------------------- /typings/useForceUpdate.d.ts: -------------------------------------------------------------------------------- 1 | import { CoreHooks } from "@rbxts/roact-hooks"; 2 | 3 | /** 4 | * Returns a callback that once called, will cause the component to re-render. 5 | * @param hooks The core hooks. 6 | */ 7 | declare function useForceUpdate(hooks: CoreHooks): () => void; 8 | 9 | export = useForceUpdate; -------------------------------------------------------------------------------- /src/getRbxtsSource.lua: -------------------------------------------------------------------------------- 1 | return function(name, org) 2 | name = name:lower() 3 | 4 | local rbxts = game:GetService("ReplicatedStorage"):WaitForChild("rbxts_include") 5 | local source = require(rbxts:WaitForChild("RuntimeLib")).getModule(script, "@" .. (org or "rbxts"), name) or {} 6 | 7 | return source.src or source.lib 8 | end -------------------------------------------------------------------------------- /src/createUseStore.lua: -------------------------------------------------------------------------------- 1 | local useStore = require(script.Parent.Hooks.useStore) 2 | 3 | local function createUseStore(roactRoduxSource) 4 | local context 5 | 6 | if roactRoduxSource then 7 | context = require(roactRoduxSource.StoreContext) 8 | end 9 | 10 | return useStore(context) 11 | end 12 | 13 | return createUseStore -------------------------------------------------------------------------------- /typings/useDebouncedText.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | /** 4 | * A shortcut for using useDebounce with a string. 5 | * 6 | * @param time The time to wait. 7 | * @param text The text. 8 | */ 9 | declare function useDebouncedText( 10 | time: number, 11 | text: string 12 | ): HookCreator; 13 | 14 | export = useDebouncedText; -------------------------------------------------------------------------------- /typings/useReactiveState.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | /** 4 | * Creates a mutable state object that causes a re-render whenever a new value is assigned to a key. 5 | * @param initialValue The initial state. 6 | */ 7 | declare function useReactiveState>( 8 | initialValue: T, 9 | ): HookCreator; 10 | 11 | export = useReactiveState; -------------------------------------------------------------------------------- /docs/Installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | ### Git Submodule 6 | 1. Clone the repository and add it as a git submodule. 7 | 2. Configure your rojo project file to point to the appropriate path. (reference [here](https://rojo.space/docs/6.x/project-format/)) 8 | 9 | ### Wally 10 | Coming soon. 11 | 12 | ### Roblox-TS 13 | 14 | Just run `npm i @rbxts/hook-bag` and you are done! -------------------------------------------------------------------------------- /typings/useMaid.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | import Maid from "@rbxts/maid"; 3 | 4 | /** 5 | * Will clear the maid after one of its dependencies change. 6 | * @param maid The maid instance. 7 | * @param dependencies The dependencies. 8 | */ 9 | declare function useMaid( 10 | maid: typeof Maid, 11 | dependencies: Array, 12 | ): HookCreator; 13 | 14 | export = useMaid; -------------------------------------------------------------------------------- /moonwave.toml: -------------------------------------------------------------------------------- 1 | title = "Roact-Hooks Bag" 2 | gitRepoUrl = "https://github.com/Rimuy/hook-bag" 3 | gitSourceBranch = "master" 4 | 5 | classOrder = ["Hooks"] 6 | 7 | # [[classOrder]] 8 | # section = "A" 9 | # classes = ["Hooks"] 10 | 11 | [docusaurus] 12 | url = "https://rimuy.github.io" 13 | baseUrl = "/hook-bag/" 14 | tagline = "Collection of custom roact hooks" 15 | 16 | [home] 17 | enabled = true 18 | includeReadme = true -------------------------------------------------------------------------------- /typings/useToggle.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | /** 4 | * State hook that tracks value of a boolean. 5 | * @param initialValue The initial value. 6 | */ 7 | declare function useToggle( 8 | initialValue: boolean, 9 | ): HookCreator< 10 | LuaTuple<[ 11 | value: boolean, 12 | toggle: (value?: boolean) => void, 13 | ]> 14 | >; 15 | 16 | export = useToggle; -------------------------------------------------------------------------------- /typings/useQueue.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | /** 4 | * State hook that implements a simple FIFO queue. 5 | * @param initialValue The initial value of the queue. 6 | */ 7 | declare function useQueue( 8 | initialValue?: Array, 9 | ): HookCreator<{ 10 | add: (element: T) => void; 11 | remove: () => void; 12 | first?: T; 13 | last?: T; 14 | size: number; 15 | }>; 16 | 17 | export = useQueue; -------------------------------------------------------------------------------- /typings/useAsync.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | /** 4 | * Handles async operations and prevents race conditions. 5 | * @param asyncCallback The callback that will return a promise. 6 | */ 7 | declare function useAsync( 8 | asyncCallback: (...args: any[]) => Promise, 9 | ): HookCreator<{ 10 | isLoading: boolean; 11 | isCancelled?: boolean; 12 | error?: T; 13 | result?: T; 14 | }>; 15 | 16 | export = useAsync; -------------------------------------------------------------------------------- /typings/useDispatch.d.ts: -------------------------------------------------------------------------------- 1 | import { CoreHooks, Dispatch, Action } from "@rbxts/roact-hooks"; 2 | 3 | /** 4 | * Returns a function that executes the `dispatch` method of the store. 5 | * You may use it to dispatch actions as needed. 6 | * @param hooks The core hooks. 7 | */ 8 | declare function useDispatch>>(hooks: CoreHooks): D; 9 | declare function useDispatch(hooks: CoreHooks): Dispatch; 10 | 11 | export = useDispatch; -------------------------------------------------------------------------------- /typings/useFlipper.d.ts: -------------------------------------------------------------------------------- 1 | import { Binding } from "@rbxts/roact"; 2 | import { HookCreator } from "./"; 3 | import BaseMotor from "@rbxts/flipper/typings/BaseMotor"; 4 | 5 | /** 6 | * Helper hook that takes a flipper motor, connects it to a binding and returns both. 7 | * @param motor The flipper motor. 8 | */ 9 | declare function useFlipper>>( 10 | motor: T, 11 | ): HookCreator, T]>>; 12 | 13 | export = useFlipper; -------------------------------------------------------------------------------- /src/Hooks/useRendersSpy.lua: -------------------------------------------------------------------------------- 1 | 2 | --[=[ 3 | Returns the amount of renders the component has since its mount. 4 | 5 | @function useRendersSpy 6 | @within Hooks 7 | @param hooks RoactHooks 8 | @return number 9 | ]=] 10 | local function useRendersSpy(hooks) 11 | local count = hooks.useValue(0) 12 | 13 | hooks.useEffect(function() 14 | count.value += 1 15 | end) 16 | 17 | return count.value 18 | end 19 | 20 | return useRendersSpy -------------------------------------------------------------------------------- /src/ts.lua: -------------------------------------------------------------------------------- 1 | local createSource = require(script.Parent.createSource) 2 | local getSource = require(script.Parent.getRbxtsSource) 3 | 4 | local roactSource = getSource("roact") 5 | local roactRoduxSource = getSource("roact-rodux") 6 | local flipperSource = getSource("flipper") 7 | local roselectSource = getSource("roselect") 8 | 9 | return createSource({ 10 | roact = roactSource, 11 | roactRodux = roactRoduxSource, 12 | flipper = flipperSource, 13 | roselect = roselectSource, 14 | }) -------------------------------------------------------------------------------- /typings/useLatest.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | import { MutableValueObject } from "@rbxts/roact-hooks"; 3 | 4 | /** 5 | * Hook that can be used to return the latest state. 6 | * 7 | * This is useful to get access to the latest version of a value, instead of getting the version from the time an asynchronous callback was created, for example. 8 | * @param value The state. 9 | */ 10 | declare function useLatest( 11 | value: T 12 | ): HookCreator>; 13 | 14 | export = useLatest; -------------------------------------------------------------------------------- /src/Hooks/useForceUpdate.lua: -------------------------------------------------------------------------------- 1 | 2 | --[=[ 3 | Returns a callback that once called, will cause the component to re-render. 4 | 5 | @function useForceUpdate 6 | @within Hooks 7 | @param hooks RoactHooks 8 | @return () -> void 9 | ]=] 10 | local function useForceUpdate(hooks) 11 | local _, dispatch = hooks.useState({}) 12 | local memoizedDispatch = hooks.useCallback(function() 13 | dispatch({}) 14 | end, { dispatch }) 15 | 16 | return memoizedDispatch 17 | end 18 | 19 | return useForceUpdate -------------------------------------------------------------------------------- /typings/useStore.d.ts: -------------------------------------------------------------------------------- 1 | import { CoreHooks } from "@rbxts/roact-hooks"; 2 | import { Store as RoduxStore } from "@rbxts/rodux"; 3 | 4 | type Store = Omit, "dispatch">; 5 | 6 | /** 7 | * Returns a reference to the same store that was passed in to the `StoreProvider` component. 8 | * 9 | * This **should not** be used frequently. 10 | * If you just want to retrieve data from the store, prefer [useSelector](#useSelector) instead. 11 | * @param hooks The core hooks. 12 | */ 13 | declare function useStore(hooks: CoreHooks): Store; 14 | 15 | export = useStore; -------------------------------------------------------------------------------- /src/merge.lua: -------------------------------------------------------------------------------- 1 | return function(...) 2 | local merged = table.create(select("#", ...)) 3 | for _, v in ipairs({...}) do 4 | if type(v) == "table" then 5 | for _ = 1, #v do 6 | table.move(v, 1, #v, 1, merged) 7 | end 8 | for k, val in pairs(v) do 9 | merged[k] = val 10 | end 11 | continue 12 | end 13 | table.insert(merged, v) 14 | end 15 | return merged 16 | end -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // required 4 | "allowSyntheticDefaultImports": true, 5 | "downlevelIteration": true, 6 | "jsx": "react", 7 | "jsxFactory": "Roact.createElement", 8 | "jsxFragmentFactory": "Roact.Fragment", 9 | "module": "commonjs", 10 | "moduleResolution": "Node", 11 | "noLib": true, 12 | "resolveJsonModule": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strict": true, 15 | "target": "ESNext", 16 | "typeRoots": ["node_modules/@rbxts"], 17 | 18 | // configurable 19 | "rootDir": "src", 20 | "outDir": "out", 21 | "baseUrl": "src", 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /typings/useCounter.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | /** 4 | * State hook that tracks a numeric value. 5 | * If no initial value is passed, it will default to 0. 6 | * 7 | * Counters can be increased/decreased by amount if you pass a number to it's function. 8 | * @param initialValue The initial value. 9 | */ 10 | declare function useCounter( 11 | initialValue?: number 12 | ): HookCreator< 13 | LuaTuple<[ 14 | count: number, 15 | increment: () => number, 16 | decrement: () => number, 17 | reset: () => void, 18 | ]> 19 | >; 20 | 21 | export = useCounter; -------------------------------------------------------------------------------- /typings/useSelector.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | import { Selector } from "@rbxts/roselect"; 3 | 4 | /** 5 | * Allows you to extract data from the store state, using a selector function. 6 | * 7 | * Selectors should be [pure](https://en.wikipedia.org/wiki/Pure_function) since they are potentially executed multiple times and at arbitrary points in time. 8 | * @param selector The selector. 9 | * @param equalityFn The callback that checks to see if the previous state is equal to the new state. 10 | */ 11 | declare function useSelector( 12 | selector: Selector, 13 | equalityFn?: (a: R, b: R) => boolean, 14 | ): HookCreator; 15 | 16 | export = useSelector; -------------------------------------------------------------------------------- /typings/useUpdateEffect.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | /** 4 | * Does the exactly same thing `useEffect` do, but ignores the first render. 5 | * @param callback The callback to execute. 6 | */ 7 | declare function useUpdateEffect( 8 | callback: () => (() => void) | void, 9 | ): HookCreator; 10 | 11 | /** 12 | * Does the exactly same thing `useEffect` do, but ignores the first render. 13 | * @param callback The callback to execute. 14 | * @param dependencies The dependencies. 15 | */ 16 | declare function useUpdateEffect( 17 | callback: () => (() => void) | void, 18 | dependencies?: Array, 19 | ): HookCreator; 20 | 21 | export = useUpdateEffect; -------------------------------------------------------------------------------- /src/Hooks/usePrevious.lua: -------------------------------------------------------------------------------- 1 | 2 | --[=[ 3 | Simply returns the previous state. 4 | 5 | @function usePrevious 6 | @within Hooks 7 | @param state S 8 | @return HookCreator 9 | ]=] 10 | local function usePrevious(state) 11 | return function(hooks) 12 | local currentValue = hooks.useValue(state) 13 | local previousValue = hooks.useValue() 14 | 15 | if currentValue.value ~= state then 16 | previousValue.value = currentValue.value 17 | currentValue.value = state 18 | end 19 | 20 | return previousValue.value 21 | end 22 | end 23 | 24 | return usePrevious -------------------------------------------------------------------------------- /src/Symbol.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | A 'Symbol' is an opaque marker type. 3 | Symbols have the type 'userdata', but when printed to the console, the name 4 | of the symbol is shown. 5 | ]] 6 | 7 | local Symbol = {} 8 | 9 | --[[ 10 | Creates a Symbol with the given name. 11 | When printed or coerced to a string, the symbol will turn into the string 12 | given as its name. 13 | ]] 14 | function Symbol.named(name) 15 | assert(type(name) == "string", "Symbols must be created using a string name!") 16 | 17 | local self = newproxy(true) 18 | 19 | local wrappedName = ("Symbol(%s)"):format(name) 20 | 21 | getmetatable(self).__tostring = function() 22 | return wrappedName 23 | end 24 | 25 | return self 26 | end 27 | 28 | return Symbol -------------------------------------------------------------------------------- /typings/useArray.d.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, BasicStateAction } from "@rbxts/roact-hooks"; 2 | import { HookCreator } from "./"; 3 | 4 | /** 5 | * Lets you manipulate an array data structure without ever needing extra utilities. 6 | * @param value The initial state of the array. 7 | */ 8 | declare function useArray( 9 | value: Array, 10 | ): HookCreator<{ 11 | array: Array; 12 | set: Dispatch>; 13 | push: (...elements: Array) => void; 14 | filter: (callback: (element: T, index: number) => boolean) => void; 15 | update: (index: number, element: T) => void; 16 | remove: (index: number) => void; 17 | clear: () => void; 18 | }>; 19 | 20 | export = useArray; -------------------------------------------------------------------------------- /typings/useTween.d.ts: -------------------------------------------------------------------------------- 1 | import { Binding } from "@rbxts/roact"; 2 | import { HookCreator } from "./"; 3 | 4 | /** 5 | * Takes a TweenInfo class and returns a binding and an object to manage the tweening. 6 | * @param tweenInfo The [TweenInfo](https://create.roblox.com/docs/reference/engine/datatypes/TweenInfo) instance. 7 | */ 8 | declare function useTween( 9 | tweenInfo: TweenInfo, 10 | ): HookCreator< 11 | LuaTuple<[Binding, { 12 | play: () => void; 13 | pause: () => void; 14 | cancel: () => void; 15 | onCompleted: ( 16 | callback: S extends RBXScriptSignal ? F : never 17 | ) => void; 18 | }]> 19 | >; 20 | 21 | export = useTween; -------------------------------------------------------------------------------- /typings/useUndo.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | type SetPresent = (newPresent: T) => void; 4 | 5 | interface State { 6 | past: Array; 7 | present: T; 8 | future: Array; 9 | } 10 | 11 | /** 12 | * Stores defined amount of previous state values and provides handles to travel through them. 13 | * @param initialPresent The initial value. 14 | */ 15 | declare function useUndo( 16 | initialPresent: T, 17 | ): HookCreator, 19 | { 20 | set: SetPresent; 21 | reset: SetPresent; 22 | undo: () => void; 23 | redo: () => void; 24 | canUndo: boolean; 25 | canRedo: boolean; 26 | } 27 | ]>>; 28 | 29 | export = useUndo; -------------------------------------------------------------------------------- /src/Hooks/useToggle.lua: -------------------------------------------------------------------------------- 1 | 2 | --[=[ 3 | State hook that tracks value of a boolean. 4 | 5 | @function useToggle 6 | @within Hooks 7 | @param initialValue boolean 8 | @return HookCreator 9 | ]=] 10 | local function useToggle(initialValue) 11 | return function(hooks) 12 | assert(type(initialValue) == "boolean", "Initial value must be a boolean") 13 | local value, setValue = hooks.useState(initialValue) 14 | 15 | local toggle = hooks.useCallback(function(v) 16 | setValue(function(currentValue) 17 | return v or not currentValue 18 | end) 19 | end, { value }) 20 | 21 | return value, toggle 22 | end 23 | end 24 | 25 | return useToggle -------------------------------------------------------------------------------- /src/Hooks/useUpdateEffect.lua: -------------------------------------------------------------------------------- 1 | 2 | --[=[ 3 | Does the exactly same thing `useEffect` do, but ignores the first render. 4 | 5 | @function useUpdateEffect 6 | @within Hooks 7 | @param callback () -> (() -> void) | void 8 | @param dependencies {any} | nil 9 | @return HookCreator 10 | ]=] 11 | local function useUpdateEffect(callback, deps) 12 | return function(hooks) 13 | local firstRender = hooks.useValue(true) 14 | 15 | hooks.useEffect(function() 16 | if firstRender.value then 17 | firstRender.value = false 18 | return 19 | end 20 | 21 | return callback() 22 | end, deps) 23 | end 24 | end 25 | 26 | return useUpdateEffect -------------------------------------------------------------------------------- /typings/usePortal.d.ts: -------------------------------------------------------------------------------- 1 | import Roact from '@rbxts/roact'; 2 | import { HookCreator } from "./"; 3 | 4 | interface UsePortalOptions { 5 | Target: Instance; 6 | DefaultShow?: boolean; 7 | DisplayName?: string; 8 | DisplayOrder?: number; 9 | IgnoreGuiInset?: boolean; 10 | OnShow?: () => void; 11 | OnHide?: () => void; 12 | OnClickOutside?: (hide: () => void) => void; 13 | } 14 | 15 | /** 16 | * This helps you render children into an element that exists outside the hierarchy of the parent component. 17 | * @param options The settings of the portal. 18 | */ 19 | declare function usePortal( 20 | options: UsePortalOptions 21 | ): HookCreator<{ 22 | Portal: () => Roact.Element; 23 | isShow: boolean; 24 | show: () => void; 25 | hide: () => void; 26 | toggle: () => void; 27 | }>; 28 | 29 | export = usePortal; -------------------------------------------------------------------------------- /src/Hooks/useFlipper.lua: -------------------------------------------------------------------------------- 1 | --[=[ 2 | Helper hook that takes a flipper motor, connects it to a binding and returns both. 3 | 4 | @function useFlipper 5 | @within Hooks 6 | @tag flipper 7 | @param motor FlipperMotor 8 | @return HookCreator 9 | ]=] 10 | local function useFlipper(flipper) 11 | return function(motor) 12 | return function(hooks) 13 | local isMotor = flipper.isMotor(motor) 14 | if not isMotor then 15 | error("Provided value is not a motor!", 2) 16 | end 17 | 18 | local m = hooks.useValue(motor) 19 | local binding, setBindingValue = hooks.useBinding(m.value:getValue()) 20 | 21 | hooks.useEffect(function() 22 | local currentMotor = m.value 23 | currentMotor:onStep(setBindingValue) 24 | 25 | return function() 26 | currentMotor:destroy() 27 | end 28 | end, { m.value }) 29 | 30 | return binding, m.value 31 | end 32 | end 33 | end 34 | 35 | return useFlipper -------------------------------------------------------------------------------- /.moonwave/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --ifm-color-primary: #0099ff; 3 | --ifm-color-primary-dark: rgb(0, 136, 227); 4 | --ifm-color-primary-darker: rgb(0, 128, 214); 5 | --ifm-color-primary-darkest: rgb(0, 119, 199); 6 | --ifm-color-primary-light: rgb(48, 172, 255); 7 | --ifm-color-primary-lighter: rgb(110, 196, 255); 8 | --ifm-color-primary-lightest: rgb(138, 207, 255); 9 | --ifm-navbar-link-color: white; 10 | --ifm-navbar-link-hover-color: #ececec; 11 | --ifm-code-font-size: 95%; 12 | } 13 | 14 | .navbar, .navbar-sidebar__brand { 15 | background: #0d47a1; 16 | } 17 | 18 | .clean-btn { 19 | color: white; 20 | } 21 | 22 | html[data-theme="light"] .footer, html[data-theme="light"] .footer--dark { 23 | background: #f0f0f0; 24 | color: black; 25 | } 26 | 27 | html[data-theme="dark"] .footer, html[data-theme="dark"] .footer--dark { 28 | background: #212224; 29 | color: white; 30 | } -------------------------------------------------------------------------------- /typings/useTimeout.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | interface UseTimeout { 4 | cancel: () => void; 5 | reset: () => void; 6 | } 7 | 8 | /** 9 | * Re-renders the component after a specified number of seconds. 10 | * Provides handles to cancel and/or reset the timeout. 11 | * @param time The amount of seconds. 12 | * @param callback The callback to execute. 13 | */ 14 | declare function useTimeout( 15 | time: number, 16 | callback: () => void, 17 | ): HookCreator; 18 | 19 | /** 20 | * Re-renders the component after a specified number of seconds. 21 | * Provides handles to cancel and/or reset the timeout. 22 | * @param time The amount of seconds. 23 | * @param callback The callback to execute. 24 | * @param onCancel The callback that executes when it's cancelled. 25 | */ 26 | declare function useTimeout( 27 | time: number, 28 | callback: () => void, 29 | onCancel: (timeLeft: number) => void, 30 | ): HookCreator; 31 | 32 | export = useTimeout; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rbxts/hook-bag", 3 | "version": "0.2.0", 4 | "description": "Collection of custom roact hooks", 5 | "main": "src/ts.lua", 6 | "types": "typings/index.d.ts", 7 | "scripts": {}, 8 | "keywords": [ 9 | "Roblox", 10 | "roact", 11 | "roact-hooks" 12 | ], 13 | "author": "", 14 | "license": "MPL-2.0", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/Rimuy/hook-bag.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/Rimuy/hook-bag/issues" 21 | }, 22 | "files": [ 23 | "src", 24 | "typings", 25 | "README.md" 26 | ], 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "devDependencies": { 31 | "@rbxts/compiler-types": "^1.3.3-types.1", 32 | "@rbxts/types": "^1.0.629", 33 | "moonwave": "^0.3.7" 34 | }, 35 | "dependencies": { 36 | "@rbxts/flipper": "^2.0.1", 37 | "@rbxts/maid": "^1.0.0-ts.1", 38 | "@rbxts/roact": "^1.4.2-ts.3", 39 | "@rbxts/roact-hooks": "^0.4.1-ts.2", 40 | "@rbxts/rodux": "^3.0.0-ts.3", 41 | "@rbxts/roselect": "^0.1.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /typings/useDebounce.d.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "./"; 2 | 3 | /** 4 | * Hook that delays invoking a function until after wait seconds have elapsed since the last time the debounced function was invoked. 5 | * The third argument is the array of values that the debounce depends on, in the same manner as `useEffect`. 6 | * The debounce timeout will start when one of the values changes. 7 | * @param time The amount of seconds to wait. 8 | * @param callback The callback to execute once the timer is completed without interruptions. 9 | */ 10 | declare function useDebounce( 11 | time: number, 12 | callback: () => void, 13 | ): HookCreator; 14 | 15 | /** 16 | * Hook that delays invoking a function until after wait seconds have elapsed since the last time the debounced function was invoked. 17 | * The third argument is the array of values that the debounce depends on, in the same manner as `useEffect`. 18 | * The debounce timeout will start when one of the values changes. 19 | * @param time The amount of seconds to wait. 20 | * @param callback The callback to execute once the timer is completed without interruptions. 21 | * @param dependencies The dependencies. 22 | */ 23 | declare function useDebounce( 24 | time: number, 25 | callback: () => void, 26 | dependencies?: Array, 27 | ): HookCreator; 28 | 29 | export = useDebounce; -------------------------------------------------------------------------------- /src/Hooks/useMaid.lua: -------------------------------------------------------------------------------- 1 | local Maid = require(script.Parent.Parent.Library.Maid) 2 | 3 | --[=[ 4 | Will clear the maid after one of its dependencies change. 5 | 6 | > TODO EXAMPLE 7 | 8 | @function useMaid 9 | @within Hooks 10 | @tag maid 11 | @param maid Maid 12 | @param dependencies {any} 13 | @return HookCreator 14 | ]=] 15 | local function useMaid(maid, deps) 16 | return function(hooks) 17 | if Maid.isMaid(maid) == false then 18 | error("Value is not a maid!", 2) 19 | end 20 | 21 | if deps then 22 | if type(deps) ~= "table" then 23 | error("Dependency list must be a table.", 2) 24 | 25 | elseif #deps == 0 then 26 | error("Dependency list should not be empty.", 2) 27 | end 28 | end 29 | 30 | local firstRender = hooks.useValue(true) 31 | 32 | hooks.useEffect(function() 33 | if firstRender.value == true then 34 | firstRender.value = false 35 | else 36 | maid:DoCleaning() 37 | end 38 | end, deps) 39 | end 40 | end 41 | 42 | return useMaid -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | import { CoreHooks } from "@rbxts/roact-hooks"; 2 | 3 | export type HookCreator = (hooks: CoreHooks) => T; 4 | 5 | export { default as useArray } from "./useArray"; 6 | export { default as useAsync } from "./useAsync"; 7 | export { default as useCounter } from "./useCounter"; 8 | export { default as useDebounce } from "./useDebounce"; 9 | export { default as useDebouncedText } from "./useDebouncedText"; 10 | export { default as useDispatch } from "./useDispatch"; 11 | export { default as useEvent } from "./useEvent"; 12 | export { default as useFlipper } from "./useFlipper"; 13 | export { default as useForceUpdate } from "./useForceUpdate"; 14 | export { default as useLatest } from "./useLatest"; 15 | export { default as useMaid } from "./useMaid"; 16 | export { default as usePortal } from "./usePortal"; 17 | export { default as usePrevious } from "./usePrevious"; 18 | export { default as useQueue } from "./useQueue"; 19 | export { default as useReactiveState } from "./useReactiveState"; 20 | export { default as useRendersSpy } from "./useRendersSpy"; 21 | export { default as useSelector } from "./useSelector"; 22 | export { default as useStore } from "./useStore"; 23 | export { default as useTimeout } from "./useTimeout"; 24 | export { default as useToggle } from "./useToggle"; 25 | export { default as useTween } from "./useTween"; 26 | export { default as useUndo } from "./useUndo"; 27 | export { default as useUpdateEffect } from "./useUpdateEffect"; -------------------------------------------------------------------------------- /src/Hooks/useQueue.lua: -------------------------------------------------------------------------------- 1 | local copy = require(script.Parent.Parent.copy) 2 | 3 | --[=[ 4 | State hook that implements a simple FIFO queue. 5 | 6 | @function useQueue 7 | @within Hooks 8 | @param initialValue {T} 9 | @return HookCreator> 10 | ]=] 11 | local function useQueue(initialValue) 12 | return function(hooks) 13 | local queue, setQueue = hooks.useState(initialValue or {}) 14 | local size = #queue 15 | 16 | local function memoized(callback) 17 | return hooks.useCallback(function(...) 18 | setQueue(callback(...)) 19 | end, { queue }) 20 | end 21 | 22 | local add = memoized(function(element) 23 | local newQueue = copy(queue) 24 | table.insert(newQueue, 1, element) 25 | 26 | return newQueue 27 | end) 28 | 29 | local remove = memoized(function() 30 | local newQueue = copy(queue) 31 | table.remove(newQueue, 1) 32 | 33 | return newQueue 34 | end) 35 | 36 | return { 37 | add = add, 38 | remove = remove, 39 | first = queue[1], 40 | last = queue[size], 41 | size = size, 42 | } 43 | end 44 | end 45 | 46 | return useQueue -------------------------------------------------------------------------------- /src/Hooks/useDispatch.lua: -------------------------------------------------------------------------------- 1 | 2 | local function noop() end 3 | 4 | --[=[ 5 | Returns a function that executes the `dispatch` method of the store. 6 | You may use it to dispatch actions as needed. 7 | 8 | ```lua 9 | local function Counter(props, hooks) 10 | local dispatch = useDispatch(hooks) 11 | 12 | return Roact.createFragment({ 13 | Label = Roact.createElement("TextLabel", { 14 | Text = props.value 15 | }), 16 | Increment = Roact.createElement(Button, { 17 | OnClick = function() 18 | dispatch({ type = "increment" }) 19 | end 20 | }) 21 | }) 22 | end 23 | ``` 24 | 25 | @function useDispatch 26 | @within Hooks 27 | @tag roact-rodux 28 | @tag rodux 29 | @param hooks RoactHooks 30 | @return () -> void 31 | ]=] 32 | local function useDispatch(useStore, useSelector) 33 | return function(hooks) 34 | local store = useStore(hooks) 35 | 36 | local dispatch = hooks.useCallback(function(action) 37 | store:dispatch(action) 38 | end, { store }) 39 | 40 | -- For creating a connection to the store if no components are listening to changes 41 | useSelector(noop)(hooks) 42 | 43 | return dispatch 44 | end 45 | end 46 | 47 | return useDispatch -------------------------------------------------------------------------------- /src/Hooks/useStore.lua: -------------------------------------------------------------------------------- 1 | local storeKeys = {"_state", "_reducer", "_connections"} 2 | 3 | local function isStore(store) 4 | if type(store) ~= "table" then 5 | return false 6 | end 7 | 8 | for _, v in ipairs(storeKeys) do 9 | if not store[v] then 10 | return false 11 | end 12 | end 13 | 14 | return true 15 | end 16 | 17 | --[=[ 18 | Returns a reference to the same store that was passed in to the `StoreProvider` component. 19 | 20 | :::caution 21 | This **should not** be used frequently. 22 | If you just want to retrieve data from the store, prefer [useSelector](#useSelector) instead. 23 | ::: 24 | 25 | @function useStore 26 | @within Hooks 27 | @tag roact-rodux 28 | @tag rodux 29 | @param hooks RoactHooks 30 | @return Store 31 | ]=] 32 | local function useStore(context) 33 | return function(hooks) 34 | if context == nil then 35 | error("Roact-Rodux path was not found. Are you sure that package is installed?", 2) 36 | end 37 | 38 | local store = hooks.useContext(context) 39 | 40 | if store == nil then 41 | error("A store value must be passed into the StoreProvider.", 3) 42 | elseif not isStore(store) then 43 | error("An invalid store was passed into the StoreProvider.", 3) 44 | end 45 | 46 | return store 47 | end 48 | end 49 | 50 | return useStore -------------------------------------------------------------------------------- /typings/useEvent.ts: -------------------------------------------------------------------------------- 1 | import { HookCreator } from "."; 2 | 3 | interface SignalWithConnect { 4 | Connect(callback: Callback): { Disconnect(): void } | { Destroy(): void }; 5 | } 6 | 7 | interface SignalWithCamelConnect { 8 | connect(callback: Callback): { disconnect(): void } | { destroy(): void }; 9 | } 10 | 11 | interface SignalWithOn { 12 | on(callback: Callback): { disconnect(): void } | { destroy(): void }; 13 | } 14 | 15 | type SignalLike = SignalWithConnect | SignalWithCamelConnect | SignalWithOn; 16 | 17 | type InferSignalCallback = Parameters< 18 | S extends SignalWithConnect 19 | ? S["Connect"] 20 | : S extends SignalWithCamelConnect 21 | ? S["connect"] 22 | : S extends SignalWithOn 23 | ? S["on"] 24 | : never 25 | >[0]; 26 | 27 | /** 28 | * Memoizes and connects a signal object to a callback listener. 29 | * @param signal The signal object. 30 | * @param onEvent The callback to execute when the event is fired. 31 | */ 32 | declare function useEvent( 33 | signal: E, 34 | onEvent: InferSignalCallback, 35 | ): HookCreator; 36 | 37 | /** 38 | * Memoizes and connects a signal object to a callback listener. 39 | * @param signal The signal object. 40 | * @param onEvent The callback to execute when the event is fired. 41 | * @param dependencies The dependencies. 42 | */ 43 | declare function useEvent( 44 | signal: E, 45 | onEvent: InferSignalCallback, 46 | dependencies?: Array 47 | ): HookCreator; 48 | 49 | export = useEvent; -------------------------------------------------------------------------------- /src/Hooks/useLatest.lua: -------------------------------------------------------------------------------- 1 | 2 | --[=[ 3 | Hook that can be used to return the latest state. 4 | 5 | :::tip 6 | This is useful to get access to the latest version of a value, instead of getting the version from the time an asynchronous callback was created, for example. 7 | ::: 8 | 9 | ```lua 10 | local function Demo(props, hooks) 11 | local count, increment = useCounter()(hooks) 12 | local latestCount = useLatest(count)(hooks) 13 | 14 | useTimeout(5, function() 15 | print(("Latest count is: %s"):format(latestCount.value)) 16 | end)(hooks) 17 | 18 | return Roact.createFragment({ 19 | Label = Roact.createElement(Label, { 20 | Text = tostring(count) 21 | }), 22 | Button = Roact.createElement(Button, { 23 | OnClick = function() 24 | increment() 25 | end 26 | }) 27 | }) 28 | end 29 | ``` 30 | 31 | @function useLatest 32 | @within Hooks 33 | @param value T 34 | @return HookCreator<{ value: T }> 35 | ]=] 36 | local function useLatest(value) 37 | return function(hooks) 38 | local latest = hooks.useValue(value) 39 | 40 | hooks.useEffect(function() 41 | latest.value = value 42 | end, { value }) 43 | 44 | return latest 45 | end 46 | end 47 | 48 | return useLatest -------------------------------------------------------------------------------- /src/Hooks/useDebouncedText.lua: -------------------------------------------------------------------------------- 1 | local useDebounce = require(script.Parent.useDebounce) 2 | local useLatest = require(script.Parent.useLatest) 3 | 4 | --[=[ 5 | A shortcut for using [useDebounce](#useDebounce) with a string. 6 | 7 | ```lua 8 | local function HookedComponent(props, hooks) 9 | local text, setText = hooks.useState("") 10 | local debouncedText = useDebouncedText(1, text)(hooks) 11 | 12 | return Roact.createFragment({ 13 | Label = Roact.createElement(Label, { 14 | Text = debouncedText 15 | }), 16 | Box = Roact.createElement("TextBox", { 17 | -- ..., 18 | [Roact.Change.Text] = function(rbx) 19 | setText(rbx.Text) 20 | end 21 | }), 22 | }) 23 | end 24 | ``` 25 | 26 | @function useDebouncedText 27 | @within Hooks 28 | @tag promise 29 | @param time number 30 | @param text string 31 | @return HookCreator 32 | ]=] 33 | local function useDebouncedText(time, text) 34 | return function(hooks) 35 | local debouncedText, setDebouncedText = hooks.useState(text) 36 | local latestText = useLatest(text)(hooks) 37 | 38 | useDebounce(time, function() 39 | setDebouncedText(latestText.value) 40 | end, { text })(hooks) 41 | 42 | return debouncedText 43 | end 44 | end 45 | 46 | return useDebouncedText -------------------------------------------------------------------------------- /src/Hooks/useReactiveState.lua: -------------------------------------------------------------------------------- 1 | local copy = require(script.Parent.Parent.copy) 2 | local useLatest = require(script.Parent.useLatest) 3 | 4 | --[=[ 5 | Creates a mutable state object that causes a re-render whenever a new value is assigned to a key. 6 | 7 | @function useReactiveState 8 | @within Hooks 9 | @param initialState T 10 | @return HookCreator 11 | ]=] 12 | local function useReactiveState(initialState) 13 | return function(hooks) 14 | if type(initialState) ~= "table" then 15 | error("Initial state is not a table!", 2) 16 | end 17 | 18 | local state, update = hooks.useState(initialState) 19 | local latestState = useLatest(state)(hooks) 20 | 21 | local reactiveState = hooks.useMemo(function() 22 | return setmetatable({}, { 23 | __index = function(_, key) 24 | return latestState.value[key] 25 | end, 26 | __newindex = function(_, key, value) 27 | local newState = copy(latestState.value) 28 | newState[key] = value 29 | update(newState) 30 | end, 31 | __tostring = function() 32 | return 'ReactiveState' 33 | end, 34 | }) 35 | end, {}) 36 | 37 | return reactiveState 38 | end 39 | end 40 | 41 | return useReactiveState -------------------------------------------------------------------------------- /src/Hooks/useAsync.lua: -------------------------------------------------------------------------------- 1 | local Promise = require(script.Parent.Parent.Library.Promise) 2 | 3 | --[=[ 4 | Handles async operations and prevents race conditions. 5 | 6 | ```lua 7 | local function fetchData() 8 | return Promise.new(function() 9 | return httpRequest("http://url...") 10 | end) 11 | end 12 | 13 | local function HookedComponent(props, hooks) 14 | local async = useAsync(fetchData)(hooks) 15 | 16 | return Roact.createFragment({ 17 | Loading = async.isLoading and Roact.createElement(...), 18 | Error = async.error and Roact.createElement(...), 19 | Result = async.result and Roact.createElement(...) 20 | }) 21 | end 22 | ``` 23 | 24 | @function useAsync 25 | @within Hooks 26 | @tag promise 27 | @param asyncCallback () -> Promise 28 | @return HookCreator> 29 | ]=] 30 | local function useAsync(asyncCallback) 31 | return function(hooks) 32 | local promise = hooks.useMemo(asyncCallback, {}) 33 | 34 | if Promise.is(promise) == false then 35 | error("Value is not an async function!", 2) 36 | end 37 | 38 | local result, update = hooks.useState({ loading = true }) 39 | 40 | hooks.useEffect(function() 41 | task.spawn(function() 42 | local status, value = promise:awaitStatus() 43 | update({ 44 | isLoading = false, 45 | isCancelled = status == Promise.Status.Cancelled, 46 | error = status == Promise.Status.Rejected and value, 47 | result = status == Promise.Status.Resolved and value, 48 | }) 49 | end) 50 | end, {}) 51 | 52 | return result 53 | end 54 | end 55 | 56 | return useAsync -------------------------------------------------------------------------------- /src/Hooks/useSelector.lua: -------------------------------------------------------------------------------- 1 | local HookConnection = require(script.Parent.Parent.HookConnection) 2 | 3 | local function defaultEqualityCheck(a, b) 4 | return a == b 5 | end 6 | 7 | --[=[ 8 | Allows you to extract data from the store state, using a selector function. 9 | 10 | :::warning 11 | Selectors should be [pure](https://en.wikipedia.org/wiki/Pure_function) since they are potentially executed multiple times and at arbitrary points in time. 12 | ::: 13 | 14 | @function useSelector 15 | @within Hooks 16 | @tag roact-rodux 17 | @tag rodux 18 | @param selector (state: S) -> R 19 | @param equalityFn ((a: any, b: any) -> boolean) | nil 20 | @return HookCreator 21 | ]=] 22 | local function useSelector(roselect, useStore) 23 | local createSelectorCreator = roselect.createSelectorCreator 24 | local defaultMemoize = roselect.defaultMemoize 25 | return function(selector, equalityFn) 26 | return function(hooks) 27 | local checkEqual = hooks.useMemo(function() 28 | if not equalityFn then 29 | equalityFn = defaultEqualityCheck 30 | end 31 | return equalityFn 32 | end, { equalityFn }) 33 | 34 | local store = useStore(hooks) 35 | local state = store:getState() 36 | local _, setState = hooks.useState(state) 37 | 38 | hooks.useEffect(function() 39 | local hookConnection = store[HookConnection] 40 | if hookConnection == nil then 41 | store[HookConnection] = { 42 | connection = store.changed:connect(function(newState) 43 | setState(newState) 44 | end), 45 | hookCount = 1, 46 | } 47 | else 48 | hookConnection.hookCount += 1 49 | end 50 | 51 | return function() 52 | if hookConnection then 53 | hookConnection.hookCount -= 1 54 | 55 | if hookConnection.hookCount == 0 then 56 | hookConnection.connection:disconnect() 57 | store[HookConnection] = nil 58 | end 59 | end 60 | end 61 | end, {}) 62 | 63 | local result = hooks.useMemo(function() 64 | return createSelectorCreator( 65 | defaultMemoize, 66 | checkEqual 67 | )( 68 | function() 69 | return state 70 | end, 71 | selector 72 | )() 73 | end, { state }) 74 | 75 | return result 76 | end 77 | end 78 | end 79 | 80 | return useSelector -------------------------------------------------------------------------------- /src/Library/Maid.lua: -------------------------------------------------------------------------------- 1 | local Maid = {} 2 | Maid.ClassName = "Maid" 3 | 4 | function Maid.new() 5 | return setmetatable({ 6 | _tasks = {} 7 | }, Maid) 8 | end 9 | 10 | function Maid.isMaid(value) 11 | return type(value) == "table" and value.ClassName == "Maid" 12 | end 13 | 14 | function Maid:__index(index) 15 | if Maid[index] then 16 | return Maid[index] 17 | else 18 | return self._tasks[index] 19 | end 20 | end 21 | 22 | function Maid:__newindex(index, newTask) 23 | if Maid[index] ~= nil then 24 | error(("'%s' is reserved"):format(tostring(index)), 2) 25 | end 26 | 27 | local tasks = self._tasks 28 | local oldTask = tasks[index] 29 | 30 | if oldTask == newTask then 31 | return 32 | end 33 | 34 | tasks[index] = newTask 35 | 36 | if oldTask then 37 | if type(oldTask) == "function" then 38 | oldTask() 39 | elseif typeof(oldTask) == "RBXScriptConnection" then 40 | oldTask:Disconnect() 41 | elseif oldTask.Destroy then 42 | oldTask:Destroy() 43 | end 44 | end 45 | end 46 | 47 | function Maid:GiveTask(task) 48 | if not task then 49 | error("Task cannot be false or nil", 2) 50 | end 51 | 52 | local taskId = #self._tasks+1 53 | self[taskId] = task 54 | 55 | if type(task) == "table" and (not task.Destroy) then 56 | warn("[Maid.GiveTask] - Gave table task without .Destroy\n\n" .. debug.traceback()) 57 | end 58 | 59 | return taskId 60 | end 61 | 62 | function Maid:GivePromise(promise) 63 | if not promise:IsPending() then 64 | return promise 65 | end 66 | 67 | local newPromise = promise.resolved(promise) 68 | local id = self:GiveTask(newPromise) 69 | 70 | newPromise:Finally(function() 71 | self[id] = nil 72 | end) 73 | 74 | return newPromise 75 | end 76 | 77 | function Maid:DoCleaning() 78 | local tasks = self._tasks 79 | 80 | for index, task in pairs(tasks) do 81 | if typeof(task) == "RBXScriptConnection" then 82 | tasks[index] = nil 83 | task:Disconnect() 84 | end 85 | end 86 | 87 | local index, task = next(tasks) 88 | while task ~= nil do 89 | tasks[index] = nil 90 | if type(task) == "function" then 91 | task() 92 | elseif typeof(task) == "RBXScriptConnection" then 93 | task:Disconnect() 94 | elseif task.Destroy then 95 | task:Destroy() 96 | end 97 | index, task = next(tasks) 98 | end 99 | end 100 | 101 | Maid.Destroy = Maid.DoCleaning 102 | 103 | return Maid -------------------------------------------------------------------------------- /src/Hooks/useEvent.lua: -------------------------------------------------------------------------------- 1 | local CONNECT_METHODS = { "Connect", "connect", "on" } 2 | local DISCONNECT_METHODS = { "Destroy", "destroy", "Disconnect", "disconnect" } 3 | 4 | local function connect(obj, callback) 5 | if typeof(obj) == "RBXScriptSignal" then 6 | return obj:Connect(callback) 7 | elseif type(obj) == "table" then 8 | for _, method in CONNECT_METHODS do 9 | if type(obj[method]) ~= "function" then 10 | continue 11 | end 12 | 13 | return obj[method](obj, callback) 14 | end 15 | end 16 | 17 | error("Invalid signal object.", 3) 18 | end 19 | 20 | local function createDisconnect(connection) 21 | return function() 22 | if connection == nil then 23 | return 24 | end 25 | 26 | for _, method in DISCONNECT_METHODS do 27 | if type(connection[method]) ~= "function" then 28 | continue 29 | end 30 | 31 | connection[method](connection) 32 | return 33 | end 34 | 35 | error("Invalid connection object.", 4) 36 | end 37 | end 38 | 39 | --[=[ 40 | Memoizes and connects a signal object to a callback listener. 41 | 42 | ```lua 43 | local function HookedComponent(props, hooks) 44 | local counter, setCount = useState(0) 45 | 46 | useEvent(mySignal, function(n) 47 | -- Assuming that the signal has a number parameter, 48 | -- the counter is incremented by `n` every time the signal fires. 49 | setCount(function(current) 50 | return current + n 51 | end) 52 | end, { counter })(hooks) 53 | 54 | return Roact.createElement("TextLabel", { 55 | -- ... 56 | Text = "Count: " .. counter, 57 | }) 58 | end 59 | ``` 60 | 61 | @function useEvent 62 | @within Hooks 63 | @param signal RBXScriptSignal 64 | @param onEvent ((...any) -> ()) 65 | @param dependencies {any} | nil 66 | @return HookCreator 67 | ]=] 68 | local function useEvent(signal, onEvent, deps) 69 | return function(hooks) 70 | hooks.useEffect(function() 71 | return createDisconnect(connect(signal, onEvent)) 72 | end, deps) 73 | end 74 | end 75 | 76 | return useEvent -------------------------------------------------------------------------------- /src/Hooks/useTimeout.lua: -------------------------------------------------------------------------------- 1 | local Promise = require(script.Parent.Parent.Library.Promise) 2 | 3 | local function noop() end 4 | 5 | --[=[ 6 | Re-renders the component after a specified number of seconds. 7 | Provides handles to cancel and/or reset the timeout. 8 | 9 | :::info 10 | This is a one time call. Once the timeout is finished, you cannot use it again. 11 | If you are looking into something that can change accordingly to its dependencies, check [useDebounce](#useDebounce). 12 | ::: 13 | 14 | ```lua 15 | local function HookedComponent(props, hooks) 16 | local text, setText = useState("") 17 | local timeout = useTimeout(3, function() 18 | setText("This took 3 seconds!") 19 | end)(hooks) 20 | 21 | return Roact.createElement("TextLabel", { 22 | -- ... 23 | Text = text, 24 | }) 25 | end 26 | ``` 27 | 28 | @function useTimeout 29 | @within Hooks 30 | @tag promise 31 | @param time number 32 | @param callback () -> void 33 | @param onCancel ((timeLeft: number) -> void) | nil 34 | @return HookCreator 35 | ]=] 36 | local function useTimeout(time, callback, onCancel) 37 | return function(hooks) 38 | local createTimeout = hooks.useCallback(function() 39 | return Promise.delay(time):andThen(function(resolve) 40 | task.spawn(function() 41 | callback() 42 | end) 43 | resolve() 44 | end):catch(noop) 45 | end, {}) 46 | 47 | local now = hooks.useValue(os.time()) 48 | local timeout = hooks.useValue(createTimeout()) 49 | 50 | local cancel = hooks.useCallback(function() 51 | if timeout.value:getStatus() == "Started" then 52 | timeout.value:cancel() 53 | if onCancel then 54 | onCancel(os.time() - now.value) 55 | end 56 | end 57 | end, { timeout.value }) 58 | 59 | local reset = hooks.useCallback(function() 60 | cancel() 61 | timeout.value = createTimeout() 62 | end, {}) 63 | 64 | return { 65 | cancel = cancel, 66 | reset = reset, 67 | } 68 | end 69 | end 70 | 71 | return useTimeout -------------------------------------------------------------------------------- /src/Hooks/useTween.lua: -------------------------------------------------------------------------------- 1 | local TweenService = game:GetService("TweenService") 2 | 3 | --[=[ 4 | Takes a TweenInfo class and returns a binding and an object to manage the tweening. 5 | 6 | > TODO EXAMPLE 7 | 8 | @function useTween 9 | @within Hooks 10 | @param tweenInfo TweenInfo 11 | @return HookCreator<(RoactBinding, UseTween)> 12 | ]=] 13 | local function useTween(tweenInfo) 14 | return function(hooks) 15 | local binding, setBinding = hooks.useBinding(0) 16 | local onCompletedConnection = hooks.useValue() 17 | local numberValue = hooks.useMemo(function() 18 | return Instance.new("NumberValue") 19 | end, {}) 20 | 21 | local tween = TweenService:Create(numberValue, tweenInfo, { Value = 1 }) 22 | 23 | hooks.useEffect(function() 24 | local connection = numberValue:GetPropertyChangedSignal("Value"):Connect(function() 25 | setBinding(numberValue.Value) 26 | end) 27 | 28 | return function() 29 | connection:Disconnect() 30 | local conn = onCompletedConnection.value 31 | 32 | if conn ~= nil then 33 | onCompletedConnection.value = nil 34 | conn:Disconnect() 35 | end 36 | 37 | numberValue:Destroy() 38 | end 39 | end, {}) 40 | 41 | local play = hooks.useCallback(function() 42 | tween:Play() 43 | end, {}) 44 | 45 | local pause = hooks.useCallback(function() 46 | tween:Pause() 47 | end, {}) 48 | 49 | local cancel = hooks.useCallback(function() 50 | tween:Cancel() 51 | numberValue.Value = 0 52 | end, {}) 53 | 54 | local onCompleted = hooks.useCallback(function(callback) 55 | if type(callback) ~= "function" then 56 | error("Callback parameter is not a function!", 3) 57 | end 58 | 59 | onCompletedConnection.value = tween.Completed:Connect(callback) 60 | end, {}) 61 | 62 | return binding, { 63 | play = play, 64 | pause = pause, 65 | cancel = cancel, 66 | onCompleted = onCompleted, 67 | } 68 | end 69 | end 70 | 71 | return useTween -------------------------------------------------------------------------------- /src/Hooks/useCounter.lua: -------------------------------------------------------------------------------- 1 | 2 | --[=[ 3 | State hook that tracks a numeric value. 4 | If no initial value is passed, it will default to 0. 5 | 6 | :::tip 7 | Counters can be increased/decreased by amount if you pass a number to it's function. 8 | ::: 9 | 10 | ```lua 11 | local function HookedComponent(props, hooks) 12 | local count, increment, decrement, reset = useCounter()(hooks) 13 | 14 | return Roact.createFragment({ 15 | Counter = Roact.createElement(MyCounter, { 16 | Text = tostring(count) 17 | }), 18 | Inc = Roact.createElement(Button, { 19 | OnClick = function() 20 | increment() 21 | end 22 | }), 23 | Dec = Roact.createElement(Button, { 24 | OnClick = function() 25 | decrement() 26 | end 27 | }), 28 | Reset = Roact.createElement(Button, { 29 | OnClick = function() 30 | reset() 31 | end 32 | }), 33 | }) 34 | end 35 | ``` 36 | 37 | @function useCounter 38 | @within Hooks 39 | @param initialValue number | nil 40 | @return HookCreator 41 | ]=] 42 | local function useCounter(initialValue) 43 | assert( 44 | type(initialValue) == "number" or type(initialValue) == "nil", 45 | "Initial value must be of the type number or nil." 46 | ) 47 | 48 | if not initialValue then 49 | initialValue = 0 50 | end 51 | 52 | return function(hooks) 53 | local count, setCount = hooks.useState(initialValue) 54 | 55 | local increment = hooks.useCallback(function(amount) 56 | setCount(function(n) 57 | return n + math.max(1, amount or 1) 58 | end) 59 | end, { count }) 60 | 61 | local decrement = hooks.useCallback(function(amount) 62 | setCount(function(n) 63 | return n - math.max(1, amount or 1) 64 | end) 65 | end, { count }) 66 | 67 | local reset = hooks.useCallback(function() 68 | setCount(initialValue) 69 | end, {}) 70 | 71 | return count, increment, decrement, reset 72 | end 73 | end 74 | 75 | return useCounter -------------------------------------------------------------------------------- /src/init.lua: -------------------------------------------------------------------------------- 1 | local createSource = require(script.createSource) 2 | 3 | --- @class Hooks 4 | 5 | --- @type HookCreator (hooks: RoactHooks) -> T 6 | --- @within Hooks 7 | 8 | --- @interface PortalOptions 9 | --- @within Hooks 10 | --- .Target Instance 11 | --- .DefaultShow boolean | nil 12 | --- .DisplayName string | nil 13 | --- .DisplayOrder number | nil 14 | --- .IgnoreGuiInset boolean | nil 15 | --- .OnShow (() -> void) | nil 16 | --- .OnHide (() -> void) | nil 17 | --- .OnClickOutside ((hide: () -> void) -> void) | nil 18 | 19 | --- @interface UseArray 20 | --- @within Hooks 21 | --- .array {T} 22 | --- .set (valueOrCallback: T | (array: T) -> T) -> void 23 | --- .push (...elements: T) -> void 24 | --- .filter (callback: (element: T, index: number) -> boolean) -> void 25 | --- .update (index: number, element: T) -> void 26 | --- .remove (index: number) -> void 27 | --- .clear () -> void 28 | 29 | --- @interface UseAsync 30 | --- @within Hooks 31 | --- .isLoading boolean 32 | --- .isCancelled boolean 33 | --- .error T | nil 34 | --- .result T | nil 35 | 36 | --- @type UseCounter (number, () -> void, () -> void, () -> void) 37 | --- @within Hooks 38 | 39 | --- @type UseFlipper (RoactBinding, FlipperMotor) 40 | --- @within Hooks 41 | 42 | --- @interface UsePortal 43 | --- @within Hooks 44 | --- .Portal Roact.FunctionComponent 45 | --- .isShow boolean 46 | --- .show () -> void 47 | --- .hide () -> void 48 | --- .toggle () -> void 49 | 50 | --- @interface UseQueue 51 | --- @within Hooks 52 | --- .add (element: T) -> void 53 | --- .remove () -> void 54 | --- .first T | nil 55 | --- .last T | nil 56 | --- .size number 57 | 58 | --- @interface UseTimeout 59 | --- @within Hooks 60 | --- .cancel () -> void 61 | --- .reset () -> void 62 | 63 | --- @type UseToggle (boolean, (value: boolean | nil) -> void) 64 | --- @within Hooks 65 | 66 | --- @interface UseTween 67 | --- @within Hooks 68 | --- .play () -> void 69 | --- .pause () -> void 70 | --- .cancel () -> void 71 | --- .onCompleted (callback: (playbackState: Enum.PlaybackState) -> void) -> void 72 | 73 | --- @interface UseUndo 74 | --- @within Hooks 75 | --- .set (newPresent: T) -> void 76 | --- .reset (newPresent: T) -> void 77 | --- .undo () -> void 78 | --- .redo () -> void 79 | --- .canUndo boolean 80 | --- .canRedo boolean 81 | 82 | --- @interface UseUndoState 83 | --- @within Hooks 84 | --- .past {T} 85 | --- .present T 86 | --- .future {T} 87 | 88 | local roactSource = script.Parent:FindFirstChild("Roact") 89 | local roactRoduxSource = script.Parent:FindFirstChild("RoactRodux") 90 | local flipperSource = script.Parent:FindFirstChild("Flipper") 91 | local roselectSource = script.Library:FindFirstChild("Roselect") 92 | 93 | return createSource({ 94 | roact = roactSource, 95 | roactRodux = roactRoduxSource, 96 | flipper = flipperSource, 97 | roselect = roselectSource, 98 | }) -------------------------------------------------------------------------------- /src/createSource.lua: -------------------------------------------------------------------------------- 1 | local hooks = script.Parent:WaitForChild("Hooks") 2 | 3 | local createUseFlipper = require(script.Parent.createUseFlipper) 4 | 5 | local createUseDispatch = require(script.Parent.createUseDispatch) 6 | local createUseSelector = require(script.Parent.createUseSelector) 7 | local createUseStore = require(script.Parent.createUseStore) 8 | 9 | local createUsePortal = require(script.Parent.createUsePortal) 10 | 11 | local useArray = require(hooks.useArray) 12 | local useAsync = require(hooks.useAsync) 13 | local useCounter = require(hooks.useCounter) 14 | local useDebounce = require(hooks.useDebounce) 15 | local useDebouncedText = require(hooks.useDebouncedText) 16 | local useEvent = require(hooks.useEvent) 17 | local useForceUpdate = require(hooks.useForceUpdate) 18 | local useLatest = require(hooks.useLatest) 19 | local useMaid = require(hooks.useMaid) 20 | local usePrevious = require(hooks.usePrevious) 21 | local useQueue = require(hooks.useQueue) 22 | local useReactiveState = require(hooks.useReactiveState) 23 | local useRendersSpy = require(hooks.useRendersSpy) 24 | local useTimeout = require(hooks.useTimeout) 25 | local useToggle = require(hooks.useToggle) 26 | local useTween = require(hooks.useTween) 27 | local useUndo = require(hooks.useUndo) 28 | local useUpdateEffect = require(hooks.useUpdateEffect) 29 | 30 | local function createSource(libraries) 31 | local roact = libraries.roact 32 | if roact == nil then 33 | error("Roact path was not found. Are you sure the package is installed?", 2) 34 | end 35 | 36 | local useStore = createUseStore(libraries.roactRodux) 37 | local useSelector = createUseSelector(libraries.roselect, useStore) 38 | local useDispatch = createUseDispatch(useStore, useSelector) 39 | 40 | return { 41 | useArray = useArray, 42 | useAsync = useAsync, 43 | useCounter = useCounter, 44 | useDebounce = useDebounce, 45 | useDebouncedText = useDebouncedText, 46 | useDispatch = useDispatch, 47 | useEvent = useEvent, 48 | useFlipper = createUseFlipper(libraries.flipper), 49 | useForceUpdate = useForceUpdate, 50 | useLatest = useLatest, 51 | useMaid = useMaid, 52 | usePortal = createUsePortal(roact), 53 | usePrevious = usePrevious, 54 | useQueue = useQueue, 55 | useReactiveState = useReactiveState, 56 | useRendersSpy = useRendersSpy, 57 | useSelector = useSelector, 58 | useStore = useStore, 59 | useTimeout = useTimeout, 60 | useToggle = useToggle, 61 | useTween = useTween, 62 | useUndo = useUndo, 63 | useUpdateEffect = useUpdateEffect, 64 | } 65 | end 66 | 67 | return createSource -------------------------------------------------------------------------------- /src/Hooks/useDebounce.lua: -------------------------------------------------------------------------------- 1 | local useTimeout = require(script.Parent.useTimeout) 2 | local useUpdateEffect = require(script.Parent.useUpdateEffect) 3 | 4 | --[=[ 5 | Hook that delays invoking a function until after wait seconds have elapsed since the last time the debounced function was invoked. 6 | 7 | The third argument is the array of values that the debounce depends on, in the same manner as `useEffect`. 8 | The debounce timeout will start when one of the values changes. 9 | 10 | :::tip 11 | You can reach the same result using the hook [below](#useDebouncedText). 12 | ::: 13 | 14 | ```lua 15 | local function HookedComponent(props, hooks) 16 | local text, setText = hooks.useState("") 17 | local debouncedText, setDebouncedText = hooks.useState(text) 18 | 19 | -- Will change debouncedText and render after 1 second of inactivity 20 | useDebounce(1, function() 21 | setDebouncedText(text) 22 | end, { text })(hooks) 23 | 24 | return Roact.createFragment({ 25 | Label = Roact.createElement(Label, { 26 | Text = debouncedText 27 | }), 28 | Box = Roact.createElement("TextBox", { 29 | -- ..., 30 | [Roact.Change.Text] = function(rbx) 31 | setText(rbx.Text) 32 | end 33 | }), 34 | }) 35 | end 36 | ``` 37 | 38 | @function useDebounce 39 | @within Hooks 40 | @tag promise 41 | @param time number 42 | @param callback () -> void 43 | @param dependencies {any} 44 | @return HookCreator<(() -> boolean, () -> void)> 45 | ]=] 46 | local function useDebounce(time, callback, deps) 47 | return function(hooks) 48 | local isCancelled = hooks.useValue(false) 49 | local isReady = hooks.useValue(false) 50 | local timeout = hooks.useValue() 51 | local createTimeout = hooks.useCallback(function() 52 | return useTimeout(time, callback, function() 53 | isCancelled.value = true 54 | isReady.value = false 55 | end)(hooks) 56 | end, {}) 57 | 58 | local cancel = hooks.useMemo(function() 59 | return timeout.value and timeout.value.cancel 60 | end, { timeout.value }) 61 | 62 | local isReadyFn = hooks.useCallback(function() 63 | return isReady.value 64 | end, { isReady.value }) 65 | 66 | useUpdateEffect(function() 67 | if timeout.value == nil or isCancelled.value then 68 | timeout.value = createTimeout() 69 | end 70 | isReady.value = true 71 | 72 | return function() 73 | if timeout.value ~= nil then 74 | timeout.value.reset() 75 | end 76 | isReady.value = false 77 | end 78 | end, deps)(hooks) 79 | 80 | return isReadyFn, cancel 81 | end 82 | end 83 | 84 | return useDebounce -------------------------------------------------------------------------------- /src/Library/Roselect.lua: -------------------------------------------------------------------------------- 1 | local function defaultEqualityCheck(a, b) 2 | return a == b 3 | end 4 | 5 | local function isArray(tbl) 6 | if type(tbl) ~= "table" then 7 | return false 8 | end 9 | 10 | for k, _ in pairs(tbl) do 11 | if type(k) ~= "number" then 12 | return false 13 | end 14 | end 15 | 16 | return true 17 | end 18 | 19 | local function reduce(tbl, callback, initialValue) 20 | tbl = tbl or {} 21 | local value = initialValue or tbl[1] 22 | 23 | for i, v in ipairs(tbl) do 24 | value = callback(value, v, i) 25 | end 26 | 27 | return value 28 | end 29 | 30 | local function areArgumentsShallowlyEqual(equalityCheck, prev, nextValue) 31 | if prev == nil or nextValue == nil or #prev ~= #nextValue then 32 | return false 33 | end 34 | 35 | for i = 1, #prev do 36 | if equalityCheck(prev[i], nextValue[i]) == false then 37 | return false 38 | end 39 | end 40 | 41 | return true 42 | end 43 | 44 | local function defaultMemoize(func, equalityCheck) 45 | if equalityCheck == nil then 46 | equalityCheck = defaultEqualityCheck 47 | end 48 | 49 | local lastArgs 50 | local lastResult 51 | 52 | return function(...) 53 | local args = {...} 54 | 55 | if areArgumentsShallowlyEqual(equalityCheck, lastArgs, args) == false then 56 | lastResult = func(unpack(args)) 57 | end 58 | 59 | lastArgs = args 60 | return lastResult 61 | end 62 | end 63 | 64 | local function getDependencies(funcs) 65 | local dependencies = if isArray(funcs[1]) then funcs[1] else funcs 66 | 67 | for _, dep in ipairs(dependencies) do 68 | if type(dep) ~= "function" then 69 | error("Selector creators expect all input-selectors to be functions.", 2) 70 | end 71 | end 72 | 73 | return dependencies 74 | end 75 | 76 | local function createSelectorCreator(memoize, ...) 77 | local memoizeOptions = {...} 78 | 79 | return function(...) 80 | local funcs = {...} 81 | 82 | local recomputations = 0 83 | local resultFunc = table.remove(funcs, #funcs) 84 | local dependencies = getDependencies(funcs) 85 | 86 | local memoizedResultFunc = memoize( 87 | function(...) 88 | recomputations += 1 89 | return resultFunc(...) 90 | end, 91 | unpack(memoizeOptions) 92 | ) 93 | 94 | local selector = setmetatable({ 95 | resultFunc = resultFunc, 96 | dependencies = dependencies, 97 | recomputations = function() 98 | return recomputations 99 | end, 100 | resetRecomputations = function() 101 | recomputations = 0 102 | return recomputations 103 | end 104 | }, { 105 | __call = memoize(function(self, ...) 106 | local params = {} 107 | 108 | for i = 1, #dependencies do 109 | table.insert(params, dependencies[i](...)) 110 | end 111 | 112 | return memoizedResultFunc(unpack(params)) 113 | end) 114 | }) 115 | 116 | return selector 117 | end 118 | end 119 | 120 | local createSelector = createSelectorCreator(defaultMemoize) 121 | 122 | local function createStructuredSelector(selectors, selectorCreator) 123 | if type(selectors) ~= "table" then 124 | error(( 125 | "createStructuredSelector expects first argument to be an object where each property is a selector, instead received a %s" 126 | ):format(type(selectors)), 2) 127 | elseif selectorCreator == nil then 128 | selectorCreator = createSelector 129 | end 130 | 131 | local keys = {} 132 | for key, _ in pairs(selectors) do 133 | table.insert(keys, key) 134 | end 135 | 136 | local funcs = table.create(#keys) 137 | for _, key in ipairs(keys) do 138 | table.insert(funcs, selectors[key]) 139 | end 140 | 141 | return selectorCreator( 142 | funcs, 143 | function(...) 144 | return reduce({...}, function(composition, value, index) 145 | composition[keys[index]] = value 146 | return composition 147 | end) 148 | end 149 | ) 150 | end 151 | 152 | return { 153 | defaultMemoize = defaultMemoize, 154 | reduce = reduce, 155 | createSelectorCreator = createSelectorCreator, 156 | createSelector = createSelector, 157 | createStructuredSelector = createStructuredSelector, 158 | } -------------------------------------------------------------------------------- /src/Hooks/useArray.lua: -------------------------------------------------------------------------------- 1 | local copy = require(script.Parent.Parent.copy) 2 | 3 | --[=[ 4 | Lets you manipulate an array data structure without ever needing extra utilities. 5 | 6 | ```lua 7 | local function HookedComponent(props, hooks) 8 | local text = hooks.useValue() 9 | local list = useArray({})(hooks) 10 | 11 | return Roact.createFragment({ 12 | Button = Roact.createElement("TextButton", { 13 | -- ... 14 | Text = "Add", 15 | [Roact.Event.MouseButton1Click] = function(rbx) 16 | if text.value then 17 | list.push(text.value) 18 | end 19 | text.value = nil 20 | end 21 | }), 22 | TextBox = Roact.createElement("TextBox", { 23 | -- ... 24 | Text = "", 25 | [Roact.Change.Text] = function(rbx) 26 | text.value = rbx.Text 27 | end 28 | }), 29 | Display = Roact.createElement("Frame", { 30 | -- ... 31 | }, { 32 | Layout = Roact.createElement("UIListLayout", { 33 | -- ... 34 | }), 35 | Roact.createFragment(list.array) 36 | }) 37 | }) 38 | end 39 | ``` 40 | 41 | @function useArray 42 | @within Hooks 43 | @param initialValue {T} | nil 44 | @return HookCreator> 45 | ]=] 46 | local function useArray(initialValue) 47 | return function(hooks) 48 | local array, setArray = hooks.useState(initialValue) 49 | 50 | local newArray = hooks.useCallback(function(callback) 51 | setArray(function(a) 52 | return callback(copy(a)) 53 | end) 54 | end, { array }) 55 | 56 | local function push(...) 57 | local elements = table.create(select("#", ...), {...}) 58 | 59 | newArray(function(arr) 60 | for _, e in pairs(elements) do 61 | table.insert(arr, e) 62 | end 63 | 64 | return arr 65 | end) 66 | end 67 | 68 | local function filter(callback) 69 | newArray(function(arr) 70 | for i, v in pairs(arr) do 71 | local assertion = callback(v, i) 72 | assert(type(assertion) == "boolean") 73 | 74 | if assertion == false then 75 | table.remove(arr, i) 76 | end 77 | end 78 | 79 | return arr 80 | end) 81 | end 82 | 83 | local function update(index, e) 84 | newArray(function(arr) 85 | arr[index] = e 86 | 87 | return arr 88 | end) 89 | end 90 | 91 | local function remove(index) 92 | setArray(function(arr) 93 | table.remove(arr, index) 94 | 95 | return arr 96 | end) 97 | end 98 | 99 | local function clear() 100 | setArray({}) 101 | end 102 | 103 | return { 104 | array = array, 105 | set = setArray, 106 | push = push, 107 | filter = filter, 108 | update = update, 109 | remove = remove, 110 | clear = clear, 111 | } 112 | end 113 | end 114 | 115 | return useArray -------------------------------------------------------------------------------- /src/Hooks/useUndo.lua: -------------------------------------------------------------------------------- 1 | local merge = require(script.Parent.Parent.merge) 2 | 3 | local UNDO_ACTION = "UNDO" 4 | local REDO_ACTION = "REDO" 5 | local SET_ACTION = "SET" 6 | local RESET_ACTION = "RESET" 7 | 8 | local INITIAL_STATE = { 9 | past = {}, 10 | future = {}, 11 | } 12 | 13 | local function reducer(state, action) 14 | local past = state.past 15 | local present = state.present 16 | local future = state.future 17 | 18 | if action.type == UNDO_ACTION then 19 | if #past == 0 then 20 | return state 21 | end 22 | 23 | local previous = past[#past] 24 | local newPast = table.create(#past - 1) 25 | 26 | for i = 1, #past - 1 do 27 | table.insert(newPast, past[i]) 28 | end 29 | 30 | local newFuture = table.create(#future + 1) 31 | table.insert(newFuture, present) 32 | 33 | for _, f in ipairs(future) do 34 | table.insert(newFuture, f) 35 | end 36 | 37 | return { 38 | past = newPast, 39 | present = previous, 40 | future = newFuture, 41 | } 42 | elseif action.type == REDO_ACTION then 43 | if #future == 0 then 44 | return state 45 | end 46 | 47 | local nextValue = future[1] 48 | local newFuture = table.create(#future - 1) 49 | 50 | for i = 2, #future do 51 | table.insert(newFuture, future[i]) 52 | end 53 | 54 | local newPast = table.create(#past + 1) 55 | for _, p in ipairs(past) do 56 | table.insert(newPast, p) 57 | end 58 | 59 | table.insert(newPast, present) 60 | 61 | return { 62 | past = newPast, 63 | present = nextValue, 64 | future = newFuture, 65 | } 66 | elseif action.type == SET_ACTION then 67 | local newPresent = action.newPresent 68 | 69 | if newPresent == present then 70 | return state 71 | end 72 | 73 | local newPast = table.create(#past + 1) 74 | for _, p in ipairs(past) do 75 | table.insert(newPast, p) 76 | end 77 | 78 | table.insert(newPast, present) 79 | 80 | return { 81 | past = newPast, 82 | present = newPresent, 83 | future = {}, 84 | } 85 | elseif action.type == RESET_ACTION then 86 | return merge(INITIAL_STATE, { present = action.newPresent }) 87 | end 88 | end 89 | 90 | --[=[ 91 | Stores defined amount of previous state values and provides handles to travel through them. 92 | 93 | > TODO EXAMPLE 94 | 95 | @function useUndo 96 | @within Hooks 97 | @param initialPresent T 98 | @return HookCreator<(UseUndoState, UseUndo)> 99 | ]=] 100 | local function useUndo(initialPresent) 101 | return function(hooks) 102 | local state, dispatch = hooks.useReducer(reducer, merge( 103 | INITIAL_STATE, 104 | { present = initialPresent } 105 | )) 106 | 107 | return hooks.useMemo(function() 108 | local function undo() 109 | dispatch({ type = UNDO_ACTION }) 110 | end 111 | 112 | local function redo() 113 | dispatch({ type = REDO_ACTION }) 114 | end 115 | 116 | local function set(newPresent) 117 | dispatch({ 118 | type = SET_ACTION, 119 | newPresent = newPresent, 120 | }) 121 | end 122 | 123 | local function reset(newPresent) 124 | dispatch({ 125 | type = RESET_ACTION, 126 | newPresent = newPresent, 127 | }) 128 | end 129 | 130 | return state, { 131 | set = set, 132 | reset = reset, 133 | undo = undo, 134 | redo = redo, 135 | canUndo = #state.past > 0, 136 | canRedo = #state.future > 0, 137 | } 138 | end, { state.past, state.future }) 139 | end 140 | end 141 | 142 | return useUndo -------------------------------------------------------------------------------- /src/Hooks/usePortal.lua: -------------------------------------------------------------------------------- 1 | local UserInputService = game:GetService("UserInputService") 2 | local Maid = require(script.Parent.Parent.Library.Maid) 3 | local merge = require(script.Parent.Parent.merge) 4 | 5 | local DEFAULT_OPTIONS = { 6 | Target = nil, -- Required! 7 | DefaultShow = true, 8 | DisplayName = "Portal", 9 | DisplayOrder = 50000, 10 | IgnoreGuiInset = false, 11 | OnShow = function() 12 | end, 13 | OnHide = function() 14 | end, 15 | OnClickOutside = function(hide) 16 | hide() 17 | end, 18 | } 19 | 20 | --[=[ 21 | This helps you render children into an element that exists outside the hierarchy of the parent component. 22 | 23 | > TODO EXAMPLE 24 | 25 | @function usePortal 26 | @within Hooks 27 | @tag roact 28 | @param options PortalOptions 29 | @return HookCreator 30 | ]=] 31 | local function usePortal(Roact) 32 | return function(options) 33 | return function(hooks) 34 | if options.Target == nil then 35 | error("Please, provide a valid target!", 3) 36 | end 37 | 38 | options = merge(DEFAULT_OPTIONS, options) 39 | 40 | local isShow, setShow = hooks.useState(options.DefaultShow) 41 | local connection = hooks.useValue() 42 | 43 | local show = hooks.useCallback(function() 44 | setShow(true) 45 | end, {}) 46 | 47 | local hide = hooks.useCallback(function() 48 | setShow(false) 49 | end, {}) 50 | 51 | local toggle = hooks.useCallback(function() 52 | setShow(not isShow) 53 | end, { isShow }) 54 | 55 | local maid = hooks.useMemo(Maid.new, {}) 56 | 57 | local registerInput = hooks.useCallback(function() 58 | if connection.value == nil then 59 | connection.value = UserInputService.InputBegan:Connect(function(input, processed) 60 | if processed == false 61 | and isShow == true 62 | and input.UserInputType == Enum.UserInputType.MouseButton1 then 63 | options.OnClickOutside(hide) 64 | maid:DoCleaning() 65 | end 66 | end) 67 | end 68 | end, { isShow }) 69 | 70 | hooks.useEffect(function() 71 | if isShow == true then 72 | registerInput() 73 | end 74 | end, {}) 75 | 76 | local triggerEvent = hooks.useCallback(function() 77 | if isShow == true then 78 | options.OnShow() 79 | if connection.value == nil then 80 | registerInput() 81 | end 82 | else 83 | options.OnHide() 84 | if connection.value ~= nil then 85 | connection.value:Disconnect() 86 | connection.value = nil 87 | end 88 | end 89 | end, { isShow }) 90 | 91 | local Portal = hooks.useCallback(function(props) 92 | local portal 93 | if isShow == true then 94 | portal = Roact.createElement(Roact.Portal, { 95 | target = options.Target, 96 | }, { 97 | [options.DisplayName] = Roact.createElement("ScreenGui", { 98 | DisplayOrder = options.DisplayOrder, 99 | IgnoreGuiInset = options.IgnoreGuiInset, 100 | }, props[Roact.Children]) 101 | }) 102 | end 103 | 104 | triggerEvent() 105 | 106 | return Roact.createFragment({ portal }) 107 | end, { isShow }) 108 | 109 | return { 110 | Portal = Portal, 111 | isShow = isShow, 112 | show = show, 113 | hide = hide, 114 | toggle = toggle, 115 | } 116 | end 117 | end 118 | end 119 | 120 | return usePortal -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /src/Library/Promise.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An implementation of Promises similar to Promise/A+. 3 | ]] 4 | 5 | local ERROR_NON_PROMISE_IN_LIST = "Non-promise value passed into %s at index %s" 6 | local ERROR_NON_LIST = "Please pass a list of promises to %s" 7 | local ERROR_NON_FUNCTION = "Please pass a handler function to %s!" 8 | local MODE_KEY_METATABLE = { __mode = "k" } 9 | 10 | local function isCallable(value) 11 | if type(value) == "function" then 12 | return true 13 | end 14 | 15 | if type(value) == "table" then 16 | local metatable = getmetatable(value) 17 | if metatable and type(rawget(metatable, "__call")) == "function" then 18 | return true 19 | end 20 | end 21 | 22 | return false 23 | end 24 | 25 | --[[ 26 | Creates an enum dictionary with some metamethods to prevent common mistakes. 27 | ]] 28 | local function makeEnum(enumName, members) 29 | local enum = {} 30 | 31 | for _, memberName in ipairs(members) do 32 | enum[memberName] = memberName 33 | end 34 | 35 | return setmetatable(enum, { 36 | __index = function(_, k) 37 | error(string.format("%s is not in %s!", k, enumName), 2) 38 | end, 39 | __newindex = function() 40 | error(string.format("Creating new members in %s is not allowed!", enumName), 2) 41 | end, 42 | }) 43 | end 44 | 45 | local Error 46 | do 47 | Error = { 48 | Kind = makeEnum("Promise.Error.Kind", { 49 | "ExecutionError", 50 | "AlreadyCancelled", 51 | "NotResolvedInTime", 52 | "TimedOut", 53 | }), 54 | } 55 | Error.__index = Error 56 | 57 | function Error.new(options, parent) 58 | options = options or {} 59 | return setmetatable({ 60 | error = tostring(options.error) or "[This error has no error text.]", 61 | trace = options.trace, 62 | context = options.context, 63 | kind = options.kind, 64 | parent = parent, 65 | createdTick = os.clock(), 66 | createdTrace = debug.traceback(), 67 | }, Error) 68 | end 69 | 70 | function Error.is(anything) 71 | if type(anything) == "table" then 72 | local metatable = getmetatable(anything) 73 | 74 | if type(metatable) == "table" then 75 | return rawget(anything, "error") ~= nil and type(rawget(metatable, "extend")) == "function" 76 | end 77 | end 78 | 79 | return false 80 | end 81 | 82 | function Error.isKind(anything, kind) 83 | assert(kind ~= nil, "Argument #2 to Promise.Error.isKind must not be nil") 84 | 85 | return Error.is(anything) and anything.kind == kind 86 | end 87 | 88 | function Error:extend(options) 89 | options = options or {} 90 | 91 | options.kind = options.kind or self.kind 92 | 93 | return Error.new(options, self) 94 | end 95 | 96 | function Error:getErrorChain() 97 | local runtimeErrors = { self } 98 | 99 | while runtimeErrors[#runtimeErrors].parent do 100 | table.insert(runtimeErrors, runtimeErrors[#runtimeErrors].parent) 101 | end 102 | 103 | return runtimeErrors 104 | end 105 | 106 | function Error:__tostring() 107 | local errorStrings = { 108 | string.format("-- Promise.Error(%s) --", self.kind or "?"), 109 | } 110 | 111 | for _, runtimeError in ipairs(self:getErrorChain()) do 112 | table.insert( 113 | errorStrings, 114 | table.concat({ 115 | runtimeError.trace or runtimeError.error, 116 | runtimeError.context, 117 | }, "\n") 118 | ) 119 | end 120 | 121 | return table.concat(errorStrings, "\n") 122 | end 123 | end 124 | 125 | --[[ 126 | Packs a number of arguments into a table and returns its length. 127 | 128 | Used to cajole varargs without dropping sparse values. 129 | ]] 130 | local function pack(...) 131 | return select("#", ...), { ... } 132 | end 133 | 134 | --[[ 135 | Returns first value (success), and packs all following values. 136 | ]] 137 | local function packResult(success, ...) 138 | return success, select("#", ...), { ... } 139 | end 140 | 141 | local function makeErrorHandler(traceback) 142 | assert(traceback ~= nil, "traceback is nil") 143 | 144 | return function(err) 145 | -- If the error object is already a table, forward it directly. 146 | -- Should we extend the error here and add our own trace? 147 | 148 | if type(err) == "table" then 149 | return err 150 | end 151 | 152 | return Error.new({ 153 | error = err, 154 | kind = Error.Kind.ExecutionError, 155 | trace = debug.traceback(tostring(err), 2), 156 | context = "Promise created at:\n\n" .. traceback, 157 | }) 158 | end 159 | end 160 | 161 | --[[ 162 | Calls a Promise executor with error handling. 163 | ]] 164 | local function runExecutor(traceback, callback, ...) 165 | return packResult(xpcall(callback, makeErrorHandler(traceback), ...)) 166 | end 167 | 168 | --[[ 169 | Creates a function that invokes a callback with correct error handling and 170 | resolution mechanisms. 171 | ]] 172 | local function createAdvancer(traceback, callback, resolve, reject) 173 | return function(...) 174 | local ok, resultLength, result = runExecutor(traceback, callback, ...) 175 | 176 | if ok then 177 | resolve(unpack(result, 1, resultLength)) 178 | else 179 | reject(result[1]) 180 | end 181 | end 182 | end 183 | 184 | local function isEmpty(t) 185 | return next(t) == nil 186 | end 187 | 188 | local Promise = { 189 | Error = Error, 190 | Status = makeEnum("Promise.Status", { "Started", "Resolved", "Rejected", "Cancelled" }), 191 | _getTime = os.clock, 192 | _timeEvent = game:GetService("RunService").Heartbeat, 193 | _unhandledRejectionCallbacks = {}, 194 | } 195 | Promise.prototype = {} 196 | Promise.__index = Promise.prototype 197 | 198 | function Promise._new(traceback, callback, parent) 199 | if parent ~= nil and not Promise.is(parent) then 200 | error("Argument #2 to Promise.new must be a promise or nil", 2) 201 | end 202 | 203 | local self = { 204 | -- The executor thread. 205 | _thread = nil, 206 | 207 | -- Used to locate where a promise was created 208 | _source = traceback, 209 | 210 | _status = Promise.Status.Started, 211 | 212 | -- A table containing a list of all results, whether success or failure. 213 | -- Only valid if _status is set to something besides Started 214 | _values = nil, 215 | 216 | -- Lua doesn't like sparse arrays very much, so we explicitly store the 217 | -- length of _values to handle middle nils. 218 | _valuesLength = -1, 219 | 220 | -- Tracks if this Promise has no error observers.. 221 | _unhandledRejection = true, 222 | 223 | -- Queues representing functions we should invoke when we update! 224 | _queuedResolve = {}, 225 | _queuedReject = {}, 226 | _queuedFinally = {}, 227 | 228 | -- The function to run when/if this promise is cancelled. 229 | _cancellationHook = nil, 230 | 231 | -- The "parent" of this promise in a promise chain. Required for 232 | -- cancellation propagation upstream. 233 | _parent = parent, 234 | 235 | -- Consumers are Promises that have chained onto this one. 236 | -- We track them for cancellation propagation downstream. 237 | _consumers = setmetatable({}, MODE_KEY_METATABLE), 238 | } 239 | 240 | if parent and parent._status == Promise.Status.Started then 241 | parent._consumers[self] = true 242 | end 243 | 244 | setmetatable(self, Promise) 245 | 246 | local function resolve(...) 247 | self:_resolve(...) 248 | end 249 | 250 | local function reject(...) 251 | self:_reject(...) 252 | end 253 | 254 | local function onCancel(cancellationHook) 255 | if cancellationHook then 256 | if self._status == Promise.Status.Cancelled then 257 | cancellationHook() 258 | else 259 | self._cancellationHook = cancellationHook 260 | end 261 | end 262 | 263 | return self._status == Promise.Status.Cancelled 264 | end 265 | 266 | self._thread = coroutine.create(function() 267 | local ok, _, result = runExecutor(self._source, callback, resolve, reject, onCancel) 268 | 269 | if not ok then 270 | reject(result[1]) 271 | end 272 | end) 273 | 274 | task.spawn(self._thread) 275 | 276 | return self 277 | end 278 | 279 | function Promise.new(executor) 280 | return Promise._new(debug.traceback(nil, 2), executor) 281 | end 282 | 283 | function Promise:__tostring() 284 | return string.format("Promise(%s)", self._status) 285 | end 286 | 287 | function Promise.defer(executor) 288 | local traceback = debug.traceback(nil, 2) 289 | local promise 290 | promise = Promise._new(traceback, function(resolve, reject, onCancel) 291 | local connection 292 | connection = Promise._timeEvent:Connect(function() 293 | connection:Disconnect() 294 | local ok, _, result = runExecutor(traceback, executor, resolve, reject, onCancel) 295 | 296 | if not ok then 297 | reject(result[1]) 298 | end 299 | end) 300 | end) 301 | 302 | return promise 303 | end 304 | 305 | -- Backwards compatibility 306 | Promise.async = Promise.defer 307 | 308 | function Promise.resolve(...) 309 | local length, values = pack(...) 310 | return Promise._new(debug.traceback(nil, 2), function(resolve) 311 | resolve(unpack(values, 1, length)) 312 | end) 313 | end 314 | 315 | function Promise.reject(...) 316 | local length, values = pack(...) 317 | return Promise._new(debug.traceback(nil, 2), function(_, reject) 318 | reject(unpack(values, 1, length)) 319 | end) 320 | end 321 | 322 | --[[ 323 | Runs a non-promise-returning function as a Promise with the 324 | given arguments. 325 | ]] 326 | function Promise._try(traceback, callback, ...) 327 | local valuesLength, values = pack(...) 328 | 329 | return Promise._new(traceback, function(resolve) 330 | resolve(callback(unpack(values, 1, valuesLength))) 331 | end) 332 | end 333 | 334 | function Promise.try(callback, ...) 335 | return Promise._try(debug.traceback(nil, 2), callback, ...) 336 | end 337 | 338 | --[[ 339 | Returns a new promise that: 340 | * is resolved when all input promises resolve 341 | * is rejected if ANY input promises reject 342 | ]] 343 | function Promise._all(traceback, promises, amount) 344 | if type(promises) ~= "table" then 345 | error(string.format(ERROR_NON_LIST, "Promise.all"), 3) 346 | end 347 | 348 | -- We need to check that each value is a promise here so that we can produce 349 | -- a proper error rather than a rejected promise with our error. 350 | for i, promise in pairs(promises) do 351 | if not Promise.is(promise) then 352 | error(string.format(ERROR_NON_PROMISE_IN_LIST, "Promise.all", tostring(i)), 3) 353 | end 354 | end 355 | 356 | -- If there are no values then return an already resolved promise. 357 | if #promises == 0 or amount == 0 then 358 | return Promise.resolve({}) 359 | end 360 | 361 | return Promise._new(traceback, function(resolve, reject, onCancel) 362 | -- An array to contain our resolved values from the given promises. 363 | local resolvedValues = {} 364 | local newPromises = {} 365 | 366 | -- Keep a count of resolved promises because just checking the resolved 367 | -- values length wouldn't account for promises that resolve with nil. 368 | local resolvedCount = 0 369 | local rejectedCount = 0 370 | local done = false 371 | 372 | local function cancel() 373 | for _, promise in ipairs(newPromises) do 374 | promise:cancel() 375 | end 376 | end 377 | 378 | -- Called when a single value is resolved and resolves if all are done. 379 | local function resolveOne(i, ...) 380 | if done then 381 | return 382 | end 383 | 384 | resolvedCount = resolvedCount + 1 385 | 386 | if amount == nil then 387 | resolvedValues[i] = ... 388 | else 389 | resolvedValues[resolvedCount] = ... 390 | end 391 | 392 | if resolvedCount >= (amount or #promises) then 393 | done = true 394 | resolve(resolvedValues) 395 | cancel() 396 | end 397 | end 398 | 399 | onCancel(cancel) 400 | 401 | -- We can assume the values inside `promises` are all promises since we 402 | -- checked above. 403 | for i, promise in ipairs(promises) do 404 | newPromises[i] = promise:andThen(function(...) 405 | resolveOne(i, ...) 406 | end, function(...) 407 | rejectedCount = rejectedCount + 1 408 | 409 | if amount == nil or #promises - rejectedCount < amount then 410 | cancel() 411 | done = true 412 | 413 | reject(...) 414 | end 415 | end) 416 | end 417 | 418 | if done then 419 | cancel() 420 | end 421 | end) 422 | end 423 | 424 | function Promise.all(promises) 425 | return Promise._all(debug.traceback(nil, 2), promises) 426 | end 427 | 428 | function Promise.fold(list, reducer, initialValue) 429 | assert(type(list) == "table", "Bad argument #1 to Promise.fold: must be a table") 430 | assert(isCallable(reducer), "Bad argument #2 to Promise.fold: must be a function") 431 | 432 | local accumulator = Promise.resolve(initialValue) 433 | return Promise.each(list, function(resolvedElement, i) 434 | accumulator = accumulator:andThen(function(previousValueResolved) 435 | return reducer(previousValueResolved, resolvedElement, i) 436 | end) 437 | end):andThen(function() 438 | return accumulator 439 | end) 440 | end 441 | 442 | function Promise.some(promises, count) 443 | assert(type(count) == "number", "Bad argument #2 to Promise.some: must be a number") 444 | 445 | return Promise._all(debug.traceback(nil, 2), promises, count) 446 | end 447 | 448 | function Promise.any(promises) 449 | return Promise._all(debug.traceback(nil, 2), promises, 1):andThen(function(values) 450 | return values[1] 451 | end) 452 | end 453 | 454 | function Promise.allSettled(promises) 455 | if type(promises) ~= "table" then 456 | error(string.format(ERROR_NON_LIST, "Promise.allSettled"), 2) 457 | end 458 | 459 | -- We need to check that each value is a promise here so that we can produce 460 | -- a proper error rather than a rejected promise with our error. 461 | for i, promise in pairs(promises) do 462 | if not Promise.is(promise) then 463 | error(string.format(ERROR_NON_PROMISE_IN_LIST, "Promise.allSettled", tostring(i)), 2) 464 | end 465 | end 466 | 467 | -- If there are no values then return an already resolved promise. 468 | if #promises == 0 then 469 | return Promise.resolve({}) 470 | end 471 | 472 | return Promise._new(debug.traceback(nil, 2), function(resolve, _, onCancel) 473 | -- An array to contain our resolved values from the given promises. 474 | local fates = {} 475 | local newPromises = {} 476 | 477 | -- Keep a count of resolved promises because just checking the resolved 478 | -- values length wouldn't account for promises that resolve with nil. 479 | local finishedCount = 0 480 | 481 | -- Called when a single value is resolved and resolves if all are done. 482 | local function resolveOne(i, ...) 483 | finishedCount = finishedCount + 1 484 | 485 | fates[i] = ... 486 | 487 | if finishedCount >= #promises then 488 | resolve(fates) 489 | end 490 | end 491 | 492 | onCancel(function() 493 | for _, promise in ipairs(newPromises) do 494 | promise:cancel() 495 | end 496 | end) 497 | 498 | -- We can assume the values inside `promises` are all promises since we 499 | -- checked above. 500 | for i, promise in ipairs(promises) do 501 | newPromises[i] = promise:finally(function(...) 502 | resolveOne(i, ...) 503 | end) 504 | end 505 | end) 506 | end 507 | 508 | function Promise.race(promises) 509 | assert(type(promises) == "table", string.format(ERROR_NON_LIST, "Promise.race")) 510 | 511 | for i, promise in pairs(promises) do 512 | assert(Promise.is(promise), string.format(ERROR_NON_PROMISE_IN_LIST, "Promise.race", tostring(i))) 513 | end 514 | 515 | return Promise._new(debug.traceback(nil, 2), function(resolve, reject, onCancel) 516 | local newPromises = {} 517 | local finished = false 518 | 519 | local function cancel() 520 | for _, promise in ipairs(newPromises) do 521 | promise:cancel() 522 | end 523 | end 524 | 525 | local function finalize(callback) 526 | return function(...) 527 | cancel() 528 | finished = true 529 | return callback(...) 530 | end 531 | end 532 | 533 | if onCancel(finalize(reject)) then 534 | return 535 | end 536 | 537 | for i, promise in ipairs(promises) do 538 | newPromises[i] = promise:andThen(finalize(resolve), finalize(reject)) 539 | end 540 | 541 | if finished then 542 | cancel() 543 | end 544 | end) 545 | end 546 | 547 | function Promise.each(list, predicate) 548 | assert(type(list) == "table", string.format(ERROR_NON_LIST, "Promise.each")) 549 | assert(isCallable(predicate), string.format(ERROR_NON_FUNCTION, "Promise.each")) 550 | 551 | return Promise._new(debug.traceback(nil, 2), function(resolve, reject, onCancel) 552 | local results = {} 553 | local promisesToCancel = {} 554 | 555 | local cancelled = false 556 | 557 | local function cancel() 558 | for _, promiseToCancel in ipairs(promisesToCancel) do 559 | promiseToCancel:cancel() 560 | end 561 | end 562 | 563 | onCancel(function() 564 | cancelled = true 565 | 566 | cancel() 567 | end) 568 | 569 | -- We need to preprocess the list of values and look for Promises. 570 | -- If we find some, we must register our andThen calls now, so that those Promises have a consumer 571 | -- from us registered. If we don't do this, those Promises might get cancelled by something else 572 | -- before we get to them in the series because it's not possible to tell that we plan to use it 573 | -- unless we indicate it here. 574 | 575 | local preprocessedList = {} 576 | 577 | for index, value in ipairs(list) do 578 | if Promise.is(value) then 579 | if value:getStatus() == Promise.Status.Cancelled then 580 | cancel() 581 | return reject(Error.new({ 582 | error = "Promise is cancelled", 583 | kind = Error.Kind.AlreadyCancelled, 584 | context = string.format( 585 | "The Promise that was part of the array at index %d passed into Promise.each was already cancelled when Promise.each began.\n\nThat Promise was created at:\n\n%s", 586 | index, 587 | value._source 588 | ), 589 | })) 590 | elseif value:getStatus() == Promise.Status.Rejected then 591 | cancel() 592 | return reject(select(2, value:await())) 593 | end 594 | 595 | -- Chain a new Promise from this one so we only cancel ours 596 | local ourPromise = value:andThen(function(...) 597 | return ... 598 | end) 599 | 600 | table.insert(promisesToCancel, ourPromise) 601 | preprocessedList[index] = ourPromise 602 | else 603 | preprocessedList[index] = value 604 | end 605 | end 606 | 607 | for index, value in ipairs(preprocessedList) do 608 | if Promise.is(value) then 609 | local success 610 | success, value = value:await() 611 | 612 | if not success then 613 | cancel() 614 | return reject(value) 615 | end 616 | end 617 | 618 | if cancelled then 619 | return 620 | end 621 | 622 | local predicatePromise = Promise.resolve(predicate(value, index)) 623 | 624 | table.insert(promisesToCancel, predicatePromise) 625 | 626 | local success, result = predicatePromise:await() 627 | 628 | if not success then 629 | cancel() 630 | return reject(result) 631 | end 632 | 633 | results[index] = result 634 | end 635 | 636 | resolve(results) 637 | end) 638 | end 639 | 640 | function Promise.is(object) 641 | if type(object) ~= "table" then 642 | return false 643 | end 644 | 645 | local objectMetatable = getmetatable(object) 646 | 647 | if objectMetatable == Promise then 648 | -- The Promise came from this library. 649 | return true 650 | elseif objectMetatable == nil then 651 | -- No metatable, but we should still chain onto tables with andThen methods 652 | return isCallable(object.andThen) 653 | elseif 654 | type(objectMetatable) == "table" 655 | and type(rawget(objectMetatable, "__index")) == "table" 656 | and isCallable(rawget(rawget(objectMetatable, "__index"), "andThen")) 657 | then 658 | -- Maybe this came from a different or older Promise library. 659 | return true 660 | end 661 | 662 | return false 663 | end 664 | 665 | function Promise.promisify(callback) 666 | return function(...) 667 | return Promise._try(debug.traceback(nil, 2), callback, ...) 668 | end 669 | end 670 | 671 | do 672 | -- uses a sorted doubly linked list (queue) to achieve O(1) remove operations and O(n) for insert 673 | 674 | -- the initial node in the linked list 675 | local first 676 | local connection 677 | 678 | function Promise.delay(seconds) 679 | assert(type(seconds) == "number", "Bad argument #1 to Promise.delay, must be a number.") 680 | -- If seconds is -INF, INF, NaN, or less than 1 / 60, assume seconds is 1 / 60. 681 | -- This mirrors the behavior of wait() 682 | if not (seconds >= 1 / 60) or seconds == math.huge then 683 | seconds = 1 / 60 684 | end 685 | 686 | return Promise._new(debug.traceback(nil, 2), function(resolve, _, onCancel) 687 | local startTime = Promise._getTime() 688 | local endTime = startTime + seconds 689 | 690 | local node = { 691 | resolve = resolve, 692 | startTime = startTime, 693 | endTime = endTime, 694 | } 695 | 696 | if connection == nil then -- first is nil when connection is nil 697 | first = node 698 | connection = Promise._timeEvent:Connect(function() 699 | local threadStart = Promise._getTime() 700 | 701 | while first ~= nil and first.endTime < threadStart do 702 | local current = first 703 | first = current.next 704 | 705 | if first == nil then 706 | connection:Disconnect() 707 | connection = nil 708 | else 709 | first.previous = nil 710 | end 711 | 712 | current.resolve(Promise._getTime() - current.startTime) 713 | end 714 | end) 715 | else -- first is non-nil 716 | if first.endTime < endTime then -- if `node` should be placed after `first` 717 | -- we will insert `node` between `current` and `next` 718 | -- (i.e. after `current` if `next` is nil) 719 | local current = first 720 | local next = current.next 721 | 722 | while next ~= nil and next.endTime < endTime do 723 | current = next 724 | next = current.next 725 | end 726 | 727 | -- `current` must be non-nil, but `next` could be `nil` (i.e. last item in list) 728 | current.next = node 729 | node.previous = current 730 | 731 | if next ~= nil then 732 | node.next = next 733 | next.previous = node 734 | end 735 | else 736 | -- set `node` to `first` 737 | node.next = first 738 | first.previous = node 739 | first = node 740 | end 741 | end 742 | 743 | onCancel(function() 744 | -- remove node from queue 745 | local next = node.next 746 | 747 | if first == node then 748 | if next == nil then -- if `node` is the first and last 749 | connection:Disconnect() 750 | connection = nil 751 | else -- if `node` is `first` and not the last 752 | next.previous = nil 753 | end 754 | first = next 755 | else 756 | local previous = node.previous 757 | -- since `node` is not `first`, then we know `previous` is non-nil 758 | previous.next = next 759 | 760 | if next ~= nil then 761 | next.previous = previous 762 | end 763 | end 764 | end) 765 | end) 766 | end 767 | end 768 | 769 | function Promise.prototype:timeout(seconds, rejectionValue) 770 | local traceback = debug.traceback(nil, 2) 771 | 772 | return Promise.race({ 773 | Promise.delay(seconds):andThen(function() 774 | return Promise.reject(rejectionValue == nil and Error.new({ 775 | kind = Error.Kind.TimedOut, 776 | error = "Timed out", 777 | context = string.format( 778 | "Timeout of %d seconds exceeded.\n:timeout() called at:\n\n%s", 779 | seconds, 780 | traceback 781 | ), 782 | }) or rejectionValue) 783 | end), 784 | self, 785 | }) 786 | end 787 | 788 | function Promise.prototype:getStatus() 789 | return self._status 790 | end 791 | 792 | --[[ 793 | Creates a new promise that receives the result of this promise. 794 | 795 | The given callbacks are invoked depending on that result. 796 | ]] 797 | function Promise.prototype:_andThen(traceback, successHandler, failureHandler) 798 | self._unhandledRejection = false 799 | 800 | -- If we are already cancelled, we return a cancelled Promise 801 | if self._status == Promise.Status.Cancelled then 802 | local promise = Promise.new(function() end) 803 | promise:cancel() 804 | 805 | return promise 806 | end 807 | 808 | -- Create a new promise to follow this part of the chain 809 | return Promise._new(traceback, function(resolve, reject, onCancel) 810 | -- Our default callbacks just pass values onto the next promise. 811 | -- This lets success and failure cascade correctly! 812 | 813 | local successCallback = resolve 814 | if successHandler then 815 | successCallback = createAdvancer(traceback, successHandler, resolve, reject) 816 | end 817 | 818 | local failureCallback = reject 819 | if failureHandler then 820 | failureCallback = createAdvancer(traceback, failureHandler, resolve, reject) 821 | end 822 | 823 | if self._status == Promise.Status.Started then 824 | -- If we haven't resolved yet, put ourselves into the queue 825 | table.insert(self._queuedResolve, successCallback) 826 | table.insert(self._queuedReject, failureCallback) 827 | 828 | onCancel(function() 829 | -- These are guaranteed to exist because the cancellation handler is guaranteed to only 830 | -- be called at most once 831 | if self._status == Promise.Status.Started then 832 | table.remove(self._queuedResolve, table.find(self._queuedResolve, successCallback)) 833 | table.remove(self._queuedReject, table.find(self._queuedReject, failureCallback)) 834 | end 835 | end) 836 | elseif self._status == Promise.Status.Resolved then 837 | -- This promise has already resolved! Trigger success immediately. 838 | successCallback(unpack(self._values, 1, self._valuesLength)) 839 | elseif self._status == Promise.Status.Rejected then 840 | -- This promise died a terrible death! Trigger failure immediately. 841 | failureCallback(unpack(self._values, 1, self._valuesLength)) 842 | end 843 | end, self) 844 | end 845 | 846 | function Promise.prototype:andThen(successHandler, failureHandler) 847 | assert(successHandler == nil or isCallable(successHandler), string.format(ERROR_NON_FUNCTION, "Promise:andThen")) 848 | assert(failureHandler == nil or isCallable(failureHandler), string.format(ERROR_NON_FUNCTION, "Promise:andThen")) 849 | 850 | return self:_andThen(debug.traceback(nil, 2), successHandler, failureHandler) 851 | end 852 | 853 | function Promise.prototype:catch(failureHandler) 854 | assert(failureHandler == nil or isCallable(failureHandler), string.format(ERROR_NON_FUNCTION, "Promise:catch")) 855 | return self:_andThen(debug.traceback(nil, 2), nil, failureHandler) 856 | end 857 | 858 | function Promise.prototype:tap(tapHandler) 859 | assert(isCallable(tapHandler), string.format(ERROR_NON_FUNCTION, "Promise:tap")) 860 | return self:_andThen(debug.traceback(nil, 2), function(...) 861 | local callbackReturn = tapHandler(...) 862 | 863 | if Promise.is(callbackReturn) then 864 | local length, values = pack(...) 865 | return callbackReturn:andThen(function() 866 | return unpack(values, 1, length) 867 | end) 868 | end 869 | 870 | return ... 871 | end) 872 | end 873 | 874 | function Promise.prototype:andThenCall(callback, ...) 875 | assert(isCallable(callback), string.format(ERROR_NON_FUNCTION, "Promise:andThenCall")) 876 | local length, values = pack(...) 877 | return self:_andThen(debug.traceback(nil, 2), function() 878 | return callback(unpack(values, 1, length)) 879 | end) 880 | end 881 | 882 | function Promise.prototype:andThenReturn(...) 883 | local length, values = pack(...) 884 | return self:_andThen(debug.traceback(nil, 2), function() 885 | return unpack(values, 1, length) 886 | end) 887 | end 888 | 889 | function Promise.prototype:cancel() 890 | if self._status ~= Promise.Status.Started then 891 | return 892 | end 893 | 894 | self._status = Promise.Status.Cancelled 895 | 896 | if self._cancellationHook then 897 | self._cancellationHook() 898 | end 899 | 900 | coroutine.close(self._thread) 901 | 902 | if self._parent then 903 | self._parent:_consumerCancelled(self) 904 | end 905 | 906 | for child in pairs(self._consumers) do 907 | child:cancel() 908 | end 909 | 910 | self:_finalize() 911 | end 912 | 913 | --[[ 914 | Used to decrease the number of consumers by 1, and if there are no more, 915 | cancel this promise. 916 | ]] 917 | function Promise.prototype:_consumerCancelled(consumer) 918 | if self._status ~= Promise.Status.Started then 919 | return 920 | end 921 | 922 | self._consumers[consumer] = nil 923 | 924 | if next(self._consumers) == nil then 925 | self:cancel() 926 | end 927 | end 928 | 929 | --[[ 930 | Used to set a handler for when the promise resolves, rejects, or is 931 | cancelled. 932 | ]] 933 | function Promise.prototype:_finally(traceback, finallyHandler) 934 | self._unhandledRejection = false 935 | 936 | local promise = Promise._new(traceback, function(resolve, reject, onCancel) 937 | local handlerPromise 938 | 939 | onCancel(function() 940 | -- The finally Promise is not a proper consumer of self. We don't care about the resolved value. 941 | -- All we care about is running at the end. Therefore, if self has no other consumers, it's safe to 942 | -- cancel. We don't need to hold out cancelling just because there's a finally handler. 943 | self:_consumerCancelled(self) 944 | 945 | if handlerPromise then 946 | handlerPromise:cancel() 947 | end 948 | end) 949 | 950 | local finallyCallback = resolve 951 | if finallyHandler then 952 | finallyCallback = function(...) 953 | local callbackReturn = finallyHandler(...) 954 | 955 | if Promise.is(callbackReturn) then 956 | handlerPromise = callbackReturn 957 | 958 | callbackReturn 959 | :finally(function(status) 960 | if status ~= Promise.Status.Rejected then 961 | resolve(self) 962 | end 963 | end) 964 | :catch(function(...) 965 | reject(...) 966 | end) 967 | else 968 | resolve(self) 969 | end 970 | end 971 | end 972 | 973 | if self._status == Promise.Status.Started then 974 | -- The promise is not settled, so queue this. 975 | table.insert(self._queuedFinally, finallyCallback) 976 | else 977 | -- The promise already settled or was cancelled, run the callback now. 978 | finallyCallback(self._status) 979 | end 980 | end) 981 | 982 | return promise 983 | end 984 | 985 | function Promise.prototype:finally(finallyHandler) 986 | assert(finallyHandler == nil or isCallable(finallyHandler), string.format(ERROR_NON_FUNCTION, "Promise:finally")) 987 | return self:_finally(debug.traceback(nil, 2), finallyHandler) 988 | end 989 | 990 | function Promise.prototype:finallyCall(callback, ...) 991 | assert(isCallable(callback), string.format(ERROR_NON_FUNCTION, "Promise:finallyCall")) 992 | local length, values = pack(...) 993 | return self:_finally(debug.traceback(nil, 2), function() 994 | return callback(unpack(values, 1, length)) 995 | end) 996 | end 997 | 998 | function Promise.prototype:finallyReturn(...) 999 | local length, values = pack(...) 1000 | return self:_finally(debug.traceback(nil, 2), function() 1001 | return unpack(values, 1, length) 1002 | end) 1003 | end 1004 | 1005 | function Promise.prototype:awaitStatus() 1006 | self._unhandledRejection = false 1007 | 1008 | if self._status == Promise.Status.Started then 1009 | local thread = coroutine.running() 1010 | 1011 | self 1012 | :finally(function() 1013 | task.spawn(thread) 1014 | end) 1015 | -- The finally promise can propagate rejections, so we attach a catch handler to prevent the unhandled 1016 | -- rejection warning from appearing 1017 | :catch( 1018 | function() end 1019 | ) 1020 | 1021 | coroutine.yield() 1022 | end 1023 | 1024 | if self._status == Promise.Status.Resolved then 1025 | return self._status, unpack(self._values, 1, self._valuesLength) 1026 | elseif self._status == Promise.Status.Rejected then 1027 | return self._status, unpack(self._values, 1, self._valuesLength) 1028 | end 1029 | 1030 | return self._status 1031 | end 1032 | 1033 | local function awaitHelper(status, ...) 1034 | return status == Promise.Status.Resolved, ... 1035 | end 1036 | 1037 | function Promise.prototype:await() 1038 | return awaitHelper(self:awaitStatus()) 1039 | end 1040 | 1041 | local function expectHelper(status, ...) 1042 | if status ~= Promise.Status.Resolved then 1043 | error((...) == nil and "Expected Promise rejected with no value." or (...), 3) 1044 | end 1045 | 1046 | return ... 1047 | end 1048 | 1049 | function Promise.prototype:expect() 1050 | return expectHelper(self:awaitStatus()) 1051 | end 1052 | 1053 | -- Backwards compatibility 1054 | Promise.prototype.awaitValue = Promise.prototype.expect 1055 | 1056 | --[[ 1057 | Intended for use in tests. 1058 | 1059 | Similar to await(), but instead of yielding if the promise is unresolved, 1060 | _unwrap will throw. This indicates an assumption that a promise has 1061 | resolved. 1062 | ]] 1063 | function Promise.prototype:_unwrap() 1064 | if self._status == Promise.Status.Started then 1065 | error("Promise has not resolved or rejected.", 2) 1066 | end 1067 | 1068 | local success = self._status == Promise.Status.Resolved 1069 | 1070 | return success, unpack(self._values, 1, self._valuesLength) 1071 | end 1072 | 1073 | function Promise.prototype:_resolve(...) 1074 | if self._status ~= Promise.Status.Started then 1075 | if Promise.is((...)) then 1076 | (...):_consumerCancelled(self) 1077 | end 1078 | return 1079 | end 1080 | 1081 | -- If the resolved value was a Promise, we chain onto it! 1082 | if Promise.is((...)) then 1083 | -- Without this warning, arguments sometimes mysteriously disappear 1084 | if select("#", ...) > 1 then 1085 | local message = string.format( 1086 | "When returning a Promise from andThen, extra arguments are " .. "discarded! See:\n\n%s", 1087 | self._source 1088 | ) 1089 | warn(message) 1090 | end 1091 | 1092 | local chainedPromise = ... 1093 | 1094 | local promise = chainedPromise:andThen(function(...) 1095 | self:_resolve(...) 1096 | end, function(...) 1097 | local maybeRuntimeError = chainedPromise._values[1] 1098 | 1099 | -- Backwards compatibility < v2 1100 | if chainedPromise._error then 1101 | maybeRuntimeError = Error.new({ 1102 | error = chainedPromise._error, 1103 | kind = Error.Kind.ExecutionError, 1104 | context = "[No stack trace available as this Promise originated from an older version of the Promise library (< v2)]", 1105 | }) 1106 | end 1107 | 1108 | if Error.isKind(maybeRuntimeError, Error.Kind.ExecutionError) then 1109 | return self:_reject(maybeRuntimeError:extend({ 1110 | error = "This Promise was chained to a Promise that errored.", 1111 | trace = "", 1112 | context = string.format( 1113 | "The Promise at:\n\n%s\n...Rejected because it was chained to the following Promise, which encountered an error:\n", 1114 | self._source 1115 | ), 1116 | })) 1117 | end 1118 | 1119 | self:_reject(...) 1120 | end) 1121 | 1122 | if promise._status == Promise.Status.Cancelled then 1123 | self:cancel() 1124 | elseif promise._status == Promise.Status.Started then 1125 | -- Adopt ourselves into promise for cancellation propagation. 1126 | self._parent = promise 1127 | promise._consumers[self] = true 1128 | end 1129 | 1130 | return 1131 | end 1132 | 1133 | self._status = Promise.Status.Resolved 1134 | self._valuesLength, self._values = pack(...) 1135 | 1136 | -- We assume that these callbacks will not throw errors. 1137 | for _, callback in ipairs(self._queuedResolve) do 1138 | coroutine.wrap(callback)(...) 1139 | end 1140 | 1141 | self:_finalize() 1142 | end 1143 | 1144 | function Promise.prototype:_reject(...) 1145 | if self._status ~= Promise.Status.Started then 1146 | return 1147 | end 1148 | 1149 | self._status = Promise.Status.Rejected 1150 | self._valuesLength, self._values = pack(...) 1151 | 1152 | -- If there are any rejection handlers, call those! 1153 | if not isEmpty(self._queuedReject) then 1154 | -- We assume that these callbacks will not throw errors. 1155 | for _, callback in ipairs(self._queuedReject) do 1156 | coroutine.wrap(callback)(...) 1157 | end 1158 | else 1159 | -- At this point, no one was able to observe the error. 1160 | -- An error handler might still be attached if the error occurred 1161 | -- synchronously. We'll wait one tick, and if there are still no 1162 | -- observers, then we should put a message in the console. 1163 | 1164 | local err = tostring((...)) 1165 | 1166 | coroutine.wrap(function() 1167 | Promise._timeEvent:Wait() 1168 | 1169 | -- Someone observed the error, hooray! 1170 | if not self._unhandledRejection then 1171 | return 1172 | end 1173 | 1174 | -- Build a reasonable message 1175 | local message = string.format("Unhandled Promise rejection:\n\n%s\n\n%s", err, self._source) 1176 | 1177 | for _, callback in ipairs(Promise._unhandledRejectionCallbacks) do 1178 | task.spawn(callback, self, unpack(self._values, 1, self._valuesLength)) 1179 | end 1180 | 1181 | if Promise.TEST then 1182 | -- Don't spam output when we're running tests. 1183 | return 1184 | end 1185 | 1186 | warn(message) 1187 | end)() 1188 | end 1189 | 1190 | self:_finalize() 1191 | end 1192 | 1193 | --[[ 1194 | Calls any :finally handlers. We need this to be a separate method and 1195 | queue because we must call all of the finally callbacks upon a success, 1196 | failure, *and* cancellation. 1197 | ]] 1198 | function Promise.prototype:_finalize() 1199 | for _, callback in ipairs(self._queuedFinally) do 1200 | -- Purposefully not passing values to callbacks here, as it could be the 1201 | -- resolved values, or rejected errors. If the developer needs the values, 1202 | -- they should use :andThen or :catch explicitly. 1203 | coroutine.wrap(callback)(self._status) 1204 | end 1205 | 1206 | self._queuedFinally = nil 1207 | self._queuedReject = nil 1208 | self._queuedResolve = nil 1209 | 1210 | -- Clear references to other Promises to allow gc 1211 | if not Promise.TEST then 1212 | self._parent = nil 1213 | self._consumers = nil 1214 | end 1215 | 1216 | task.defer(coroutine.close, self._thread) 1217 | end 1218 | 1219 | function Promise.prototype:now(rejectionValue) 1220 | local traceback = debug.traceback(nil, 2) 1221 | if self._status == Promise.Status.Resolved then 1222 | return self:_andThen(traceback, function(...) 1223 | return ... 1224 | end) 1225 | else 1226 | return Promise.reject(rejectionValue == nil and Error.new({ 1227 | kind = Error.Kind.NotResolvedInTime, 1228 | error = "This Promise was not resolved in time for :now()", 1229 | context = ":now() was called at:\n\n" .. traceback, 1230 | }) or rejectionValue) 1231 | end 1232 | end 1233 | 1234 | function Promise.retry(callback, times, ...) 1235 | assert(isCallable(callback), "Parameter #1 to Promise.retry must be a function") 1236 | assert(type(times) == "number", "Parameter #2 to Promise.retry must be a number") 1237 | 1238 | local args, length = { ... }, select("#", ...) 1239 | 1240 | return Promise.resolve(callback(...)):catch(function(...) 1241 | if times > 0 then 1242 | return Promise.retry(callback, times - 1, unpack(args, 1, length)) 1243 | else 1244 | return Promise.reject(...) 1245 | end 1246 | end) 1247 | end 1248 | 1249 | function Promise.retryWithDelay(callback, times, seconds, ...) 1250 | assert(isCallable(callback), "Parameter #1 to Promise.retry must be a function") 1251 | assert(type(times) == "number", "Parameter #2 (times) to Promise.retry must be a number") 1252 | assert(type(seconds) == "number", "Parameter #3 (seconds) to Promise.retry must be a number") 1253 | 1254 | local args, length = { ... }, select("#", ...) 1255 | 1256 | return Promise.resolve(callback(...)):catch(function(...) 1257 | if times > 0 then 1258 | Promise.delay(seconds):await() 1259 | 1260 | return Promise.retryWithDelay(callback, times - 1, seconds, unpack(args, 1, length)) 1261 | else 1262 | return Promise.reject(...) 1263 | end 1264 | end) 1265 | end 1266 | 1267 | function Promise.fromEvent(event, predicate) 1268 | predicate = predicate or function() 1269 | return true 1270 | end 1271 | 1272 | return Promise._new(debug.traceback(nil, 2), function(resolve, _, onCancel) 1273 | local connection 1274 | local shouldDisconnect = false 1275 | 1276 | local function disconnect() 1277 | connection:Disconnect() 1278 | connection = nil 1279 | end 1280 | 1281 | -- We use shouldDisconnect because if the callback given to Connect is called before 1282 | -- Connect returns, connection will still be nil. This happens with events that queue up 1283 | -- events when there's nothing connected, such as RemoteEvents 1284 | 1285 | connection = event:Connect(function(...) 1286 | local callbackValue = predicate(...) 1287 | 1288 | if callbackValue == true then 1289 | resolve(...) 1290 | 1291 | if connection then 1292 | disconnect() 1293 | else 1294 | shouldDisconnect = true 1295 | end 1296 | elseif type(callbackValue) ~= "boolean" then 1297 | error("Promise.fromEvent predicate should always return a boolean") 1298 | end 1299 | end) 1300 | 1301 | if shouldDisconnect and connection then 1302 | return disconnect() 1303 | end 1304 | 1305 | onCancel(disconnect) 1306 | end) 1307 | end 1308 | 1309 | function Promise.onUnhandledRejection(callback) 1310 | table.insert(Promise._unhandledRejectionCallbacks, callback) 1311 | 1312 | return function() 1313 | local index = table.find(Promise._unhandledRejectionCallbacks, callback) 1314 | 1315 | if index then 1316 | table.remove(Promise._unhandledRejectionCallbacks, index) 1317 | end 1318 | end 1319 | end 1320 | 1321 | return Promise --------------------------------------------------------------------------------