Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(pnpm install:*)",
"WebSearch",
"WebFetch(domain:github.com)"
"WebFetch(domain:github.com)",
"Bash(npx astro:*)",
"WebFetch(domain:gsap.com)",
"Read(//c/Program Files/GitHub CLI//**)",
"Read(//c/Program Files \\(x86\\)/**)",
"Read(//c/Users/clement/**)",
"Bash(\"/c/Program Files/GitHub CLI/gh.exe\" pr create --title \"feat: useScrollTrigger, useTransform, useEffectEvent\" --body \"$\\(cat <<'EOF'\n## Summary\n\n- **useScrollTrigger** — High-performance scroll progress tracking with GSAP ScrollTrigger-like position syntax. Zero per-frame DOM reads, zero React re-renders. Full control over scroll-driven effects for scrollytelling.\n- **useTransform / TransformProvider** — Context-based transform accumulation so child scroll triggers stay accurate when parents are translated programmatically \\(parallax\\).\n- **useEffectEvent** — Polyfill for React's experimental `useEffectEvent` \\(stable callback ref for React 17+\\).\n- **Dedicated /scroll-trigger playground** with 9 interactive demos: basic progress, direction, enter/leave events, position combinations, steps, offset, disabled toggle, CSS custom properties, and TransformProvider.\n- **Build migration** from pnpm+tsup to bun+tsdown.\n\n### useScrollTrigger features\n- Position syntax: `\"element-position viewport-position\"` \\(`top`, `center`, `bottom`, or pixel values\\)\n- Callbacks: `onEnter`, `onLeave`, `onProgress` with `progress`, `direction`, `isActive`, `height`, `steps`\n- External rect support: share a single `useRect` across multiple triggers on the same element\n- Lenis integration: automatic, with native scroll fallback\n- Infinite scroll support via `modulo`\n- TransformProvider compensation for parallax offsets\n\n## Test plan\n- [ ] Run `bun run build` — verify clean build\n- [ ] Run `bun dev` — verify playground loads at `/scroll-trigger`\n- [ ] Scroll through all 9 demo sections\n- [ ] Verify direction indicator updates in Basic Progress\n- [ ] Verify enter/leave event log shows correct direction arrows\n- [ ] Verify TransformProvider demo shows parallax compensation\n- [ ] Test with Lenis provider wrapping the app\nEOF\n\\)\" 2>&1)",
"Bash(\"/c/Program Files/GitHub:*)",
"WebFetch(domain:tympanus.net)",
"mcp__chrome-devtools__list_pages",
"mcp__chrome-devtools__navigate_page"
]
}
}
28 changes: 28 additions & 0 deletions bash.exe.stackdump
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
Stack trace:
Frame Function Args
0007FFFFB010 00021005FE8E (000210285F68, 00021026AB6E, 000000000000, 0007FFFF9F10) msys-2.0.dll+0x1FE8E
0007FFFFB010 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFB2E8) msys-2.0.dll+0x67F9
0007FFFFB010 000210046832 (000210286019, 0007FFFFAEC8, 000000000000, 000000000000) msys-2.0.dll+0x6832
0007FFFFB010 000210068CF6 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28CF6
0007FFFFB010 000210068E24 (0007FFFFB020, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28E24
0007FFFFB2F0 00021006A225 (0007FFFFB020, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A225
End of stack trace
Loaded modules:
000100400000 bash.exe
7FF808960000 ntdll.dll
7FF8077F0000 KERNEL32.DLL
7FF805240000 KERNELBASE.dll
7FF808730000 USER32.dll
7FF805B00000 win32u.dll
000210040000 msys-2.0.dll
7FF8085C0000 GDI32.dll
7FF805BE0000 gdi32full.dll
7FF805B30000 msvcp_win.dll
7FF8057B0000 ucrtbase.dll
7FF806F00000 advapi32.dll
7FF806700000 msvcrt.dll
7FF808510000 sechost.dll
7FF806FC0000 RPCRT4.dll
7FF804740000 CRYPTBASE.DLL
7FF805A50000 bcryptPrimitives.dll
7FF8085F0000 IMM32.DLL
147 changes: 78 additions & 69 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,69 +1,78 @@
{
"name": "hamo",
"version": "1.0.0-dev.10",
"description": "hamo means hook, do the math.",
"type": "module",
"workspaces": [
"packages/*",
"playground",
"playground/*"
],
"sideEffects": false,
"unpkg": "./dist/hamo.mjs",
"main": "./dist/hamo.mjs",
"module": "./dist/hamo.mjs",
"types": "./dist/hamo.d.ts",
"exports": {
".": {
"types": "./dist/hamo.d.ts",
"default": "./dist/hamo.mjs"
},
"./dist/*": "./dist/*"
},
"files": [
"dist"
],
"author": "darkroom.engineering",
"license": "MIT",
"bugs": {
"url": "https://github.com/darkroomengineering/hamo/issues"
},
"homepage": "https://github.com/darkroomengineering/hamo",
"repository": {
"type": "git",
"url": "https://github.com/darkroomengineering/hamo"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/darkroomengineering"
},
"scripts": {
"build": "tsdown",
"dev": "bun run --parallel dev:build dev:playground",
"dev:build": "tsdown --watch",
"dev:playground": "bun --filter playground dev",
"version:dev": "npm version prerelease --preid dev --force --no-git-tag-version",
"version:patch": "npm version patch --force --no-git-tag-version",
"version:minor": "npm version minor --force --no-git-tag-version",
"version:major": "npm version major --force --no-git-tag-version",
"postversion": "bun run build",
"publish:main": "npm publish",
"publish:dev": "npm publish --tag dev"
},
"keywords": [
"react",
"hooks"
],
"devDependencies": {
"@biomejs/biome": "1.9.4",
"tsdown": "^0.21.4",
"typescript": "^5.4.5"
},
"dependencies": {
"nanoevents": "^9.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
}
{
"name": "hamo",
"version": "1.0.0-dev.13",
"description": "hamo means hook, do the math.",
"type": "module",
"workspaces": [
"packages/*",
"playground",
"playground/*"
],
"sideEffects": false,
"unpkg": "./dist/hamo.mjs",
"main": "./dist/hamo.mjs",
"module": "./dist/hamo.mjs",
"types": "./dist/hamo.d.ts",
"exports": {
".": {
"types": "./dist/hamo.d.ts",
"default": "./dist/hamo.mjs"
},
"./scroll-trigger": {
"types": "./dist/scroll-trigger.d.ts",
"default": "./dist/scroll-trigger.mjs"
},
"./scroll-trigger/debugger": {
"types": "./dist/scroll-trigger/debugger.d.ts",
"default": "./dist/scroll-trigger/debugger.mjs"
},
"./dist/*": "./dist/*"
},
"files": [
"dist"
],
"author": "darkroom.engineering",
"license": "MIT",
"bugs": {
"url": "https://github.com/darkroomengineering/hamo/issues"
},
"homepage": "https://github.com/darkroomengineering/hamo",
"repository": {
"type": "git",
"url": "https://github.com/darkroomengineering/hamo"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/darkroomengineering"
},
"scripts": {
"build": "tsdown",
"dev": "bun run --parallel dev:build dev:playground",
"dev:build": "tsdown --watch",
"dev:playground": "bun --filter playground dev",
"version:dev": "npm version prerelease --preid dev --force --no-git-tag-version",
"version:patch": "npm version patch --force --no-git-tag-version",
"version:minor": "npm version minor --force --no-git-tag-version",
"version:major": "npm version major --force --no-git-tag-version",
"postversion": "bun run build",
"publish:main": "npm publish",
"publish:dev": "npm publish --tag dev"
},
"keywords": [
"react",
"hooks"
],
"devDependencies": {
"@biomejs/biome": "1.9.4",
"tsdown": "^0.21.4",
"typescript": "^5.4.5"
},
"dependencies": {
"nanoevents": "^9.0.0"
},
"peerDependencies": {
"lenis": "^1.3.19",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
}
5 changes: 5 additions & 0 deletions packages/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export {
useDebouncedCallback,
useTimeout,
} from './src/use-debounce'
export { useEffectEvent } from './src/use-effect-event'
export { useObjectFit } from './src/use-object-fit'
export { useIntersectionObserver } from './src/use-intersection-observer'
export { useScrollTrigger } from './src/use-scroll-trigger'
export { useTransform, TransformProvider, TransformContext } from './src/use-transform'
export type { Rect } from './src/use-rect'
export type { UseScrollTriggerOptions } from './src/use-scroll-trigger'
export type { Transform, TransformRef } from './src/use-transform'
1 change: 1 addition & 0 deletions packages/react/scroll-trigger/debugger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Debugger } from '../src/use-scroll-trigger/debugger'
1 change: 1 addition & 0 deletions packages/react/scroll-trigger/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useScrollTrigger } from '../src/use-scroll-trigger'
14 changes: 1 addition & 13 deletions packages/react/src/use-debounce/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useRef,
useState,
} from 'react'
import { useEffectEvent } from '../use-effect-event'

export type DebouncedFunction<T extends (...args: any[]) => void> = ((
...args: Parameters<T>
Expand Down Expand Up @@ -42,19 +43,6 @@ function timeout(callback: (...args: any[]) => void, delay: number) {
return () => clearTimeout(timeout)
}

function useEffectEvent<T extends (...args: any[]) => any>(callback: T): T {
const callbackRef = useRef(callback)
callbackRef.current = callback

const [memoizedCallback] = useState(
() =>
(...args: Parameters<T>) =>
callbackRef.current(...args)
)

return memoizedCallback as T
}

export function useDebouncedEffect(
_callback: () => void,
delay: number,
Expand Down
114 changes: 114 additions & 0 deletions packages/react/src/use-effect-event/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# useEffectEvent

A polyfill for React's experimental [`useEffectEvent`](https://react.dev/reference/react/useEffectEvent) hook. Returns a stable function reference that always calls the latest version of your callback, without needing to be listed in effect dependency arrays.

React's `useEffectEvent` is still experimental and not available in any stable release. This implementation provides the same core behavior for React 17+ projects.

## Usage

```jsx
import { useEffectEvent } from 'hamo'

function Chat({ url, onMessage }) {
const handleMessage = useEffectEvent(onMessage)

useEffect(() => {
const ws = new WebSocket(url)
ws.addEventListener('message', handleMessage)

return () => ws.close()
}, [url]) // handleMessage doesn't need to be in deps
}
```

## Parameters

- `callback`: The function to wrap. Can accept any arguments and return any value.

## Return Value

A stable function with the same signature as your callback. The identity never changes across renders, but calling it always invokes the latest `callback` from the most recent render.

## How It Works

The hook stores your callback in a ref (updated every render) and returns a memoized wrapper created once via lazy `useState`. The wrapper delegates to the ref on each call, so:

- The returned function has a **stable identity** (same reference every render)
- It always reads the **latest props and state** at call time
- It can safely be omitted from effect dependency arrays

## Differences from React's Experimental Version

| | React `useEffectEvent` | hamo `useEffectEvent` |
|---|---|---|
| Availability | Experimental, not in stable React | React 17+ |
| Identity | Intentionally unstable (changes every render) | Stable (same reference every render) |
| Callable from | Effects and other Effect Events only | Anywhere (effects, event handlers, callbacks) |
| Lint enforcement | ESLint plugin enforces constraints | No restrictions |

The stable identity in hamo's version is a practical trade-off — it makes the hook more versatile (usable in event handlers, passed as props) while still solving the core problem of reading latest values without re-triggering effects.

## Examples

### Interval with Latest State

```jsx
import { useEffect, useState } from 'react'
import { useEffectEvent } from 'hamo'

function Counter() {
const [count, setCount] = useState(0)
const [step, setStep] = useState(1)

const onTick = useEffectEvent(() => {
setCount((c) => c + step) // always reads latest step
})

useEffect(() => {
const id = setInterval(onTick, 1000)
return () => clearInterval(id)
}, []) // no need to restart interval when step changes

return (
<div>
<p>{count}</p>
<input
type="number"
value={step}
onChange={(e) => setStep(Number(e.target.value))}
/>
</div>
)
}
```

### Effect Without Unnecessary Re-runs

```jsx
import { useEffect } from 'react'
import { useEffectEvent } from 'hamo'

function Logger({ data, onLog }) {
const log = useEffectEvent(onLog)

useEffect(() => {
log(data) // always calls latest onLog
}, [data]) // effect only re-runs when data changes, not when onLog changes
}
```

### Scroll Listener with Latest Callback

```jsx
import { useEffect } from 'react'
import { useEffectEvent } from 'hamo'

function useScroll(callback) {
const handler = useEffectEvent(callback)

useEffect(() => {
window.addEventListener('scroll', handler)
return () => window.removeEventListener('scroll', handler)
}, []) // listener attached once, always calls latest callback
}
```
16 changes: 16 additions & 0 deletions packages/react/src/use-effect-event/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useRef, useState } from 'react'

export function useEffectEvent<T extends (...args: any[]) => any>(
callback: T
): T {
const callbackRef = useRef(callback)
callbackRef.current = callback

const [memoizedCallback] = useState(
() =>
(...args: Parameters<T>) =>
callbackRef.current(...args)
)

return memoizedCallback as T
}
Loading
Loading