Skip to content

feat: v1.0.0 hardening#12

Open
arzafran wants to merge 23 commits into
mainfrom
feat/v1
Open

feat: v1.0.0 hardening#12
arzafran wants to merge 23 commits into
mainfrom
feat/v1

Conversation

@arzafran

@arzafran arzafran commented Jun 5, 2026

Copy link
Copy Markdown
Member

What this does

Ships hamo as a real 1.0.0. The branch hardens the existing hooks, ports three new scroll hooks, restructures into a monorepo, and clears the package for a clean stable publish.

No existing public hook changed shape; current imports keep working.

Summary

Types & correctness

  • Initial value on every useRef — fixes 4 @types/react 19 type errors (tsc --noEmit clean).
  • useIntersectionObserver: real lazy conditional return type (removed the as cast), stable callback ref, number[] threshold support.
  • useLazyState: previousValue now reports the real prior value (was always undefined).
  • useDebouncedCallback / useResizeObserver cancel pending timers on unmount (no callbacks after teardown).

Maintainability pass (whole-codebase review)

  • useScrollTrigger: removed a broken always-true isNumber guard; the four repeated position-parsing blocks collapse into one resolveAnchor helper; corrected debug-effect dependency arrays (Biome hooks-lint 0 warnings, down from 9); un-exported the internal modulo.
  • useTransform: dropped structuredClone from getTransform() (it ran on every scroll tick) in favour of building the accumulated transform directly; a createTransform() factory replaces the cloned inits; the parent-inherit subscription is a stable identity so it isn't recreated every render.
  • Shared debounce configuseRect, useResizeObserver, and useWindowSize each carried a verbatim copy of the module-level debounce default + setDebounce. Extracted to one debounce-config.ts factory; each hook keeps its own independent config instance, so useX.setDebounce semantics are unchanged.
  • useEffectEvent rewritten to the canonical useInsertionEffect + useCallback ponyfill; the hand-rolled callbackRef pattern that four hooks reimplemented now routes through it.
  • useResizeObserver: the shared observer is reset to null after disconnect(), so a later observe() re-creates it.
  • Type tightening: deps: any[]DependencyList; removed {} as Rect casts; useDebouncedCallback preserves argument tuples; DebouncedFunction exported from the barrel.

New hooks — ported from #11 (@clementroche)

  • useScrollTrigger — scroll-progress tracking with GSAP-style position syntax, optional Lenis integration with graceful native-scroll fallback. Debug overlay behind the hamo/scroll-trigger/debugger subpath.
  • useTransform / TransformProvider — context-based transform accumulation for parallax compensation.
  • useEffectEvent — promoted to a public hook; use-debounce consumes it (v1's unmount-cleanup implementation was kept).

Packaging / structure

  • Library now lives in packages/hamo/; playground/ is a sibling workspace that links it via workspace:* (the old file:.. hack that silently pulled the published 0.3.2 is gone). Root is a private workspace root whose scripts delegate via bun --filter hamo.
  • ESM-only build via tsdown unbundle, per-file "use client" so useObjectFit stays usable in Server Components. (0.3.2 was already ESM-only; the target stack — Next.js, React Router, TanStack, Vite — is ESM-native, and CJS consumers can load it via dynamic import().)
  • Zero runtime dependencies — both the resize and scroll-trigger store emitters are inlined (no nanoevents). Peers: react >=18 and lenis >=1.3 (used by useScrollTrigger; the hook falls back to native scroll when no Lenis instance is mounted).- engines.node >=18; removed the dead legacy docs/ directory.

Security

  • happy-dom^20 — fixes a critical VM context-escape RCE (GHSA-37j7-fg3j-429f) plus 2 highs in the test environment.
  • Cleared all playground Dependabot advisories — bun audit reports 0 vulnerabilities. The playground is not published, so none of this reaches consumers.

Tooling & tests

  • Single toolchain: Bun (runtime, package manager, test runner, workspaces) + Biome 2 (lint + format, rules-of-hooks). TypeScript 6.
  • bun:test suite (46 tests): render smoke tests, SSR renderToString safety, and a debounce-cancel-on-unmount leak regression.
  • ci.yml (React 18 + 19 matrix) and publish.yml retargeted to packages/hamo; least-privilege permissions; lockfile committed.
  • Publishing uses npm trusted publishing (OIDC) with provenance — no NPM_TOKEN secret. Triggered by creating a GitHub Release.

v1-prep pass (final cleanup on this branch)

A round of release hardening after review:

  • ESM-only: dropped the speculative dual CJS build (0.3.2 never shipped CJS, so there are no CJS consumers to preserve). Removed the require export conditions, .d.cts, and the legacy main field.
  • Dropped published sourcemaps: the output is modern, unminified, tree-shaken es2022 — maps only bloated the tarball.
  • Bumped hamo to TypeScript 6; moved the yaml/esbuild overrides to the workspace root (where bun honours them) and bumped playground astro, restoring bun audit to 0.
  • Switched the publish workflow to tokenless OIDC trusted publishing.
  • Cross-model (Codex) review flagged that the main entry statically imports lenis/react via useScrollTrigger, so an optional lenis peer wasn't a robust guarantee (could fail to resolve in Vite dev / SSR for consumers who skipped it). Made lenis a required peer; the runtime native-scroll fallback is unchanged.

Combined effect on the published package: 65.6 kB → 16.8 kB packed (−74%), 95 → 33 files.

Test Plan

  • bun run typecheck — 0 errors
  • bun run test — 46 pass / 0 fail (React 18 + 19 in CI)
  • bun run lint (biome) — clean, 0 warnings
  • bun run build — single ESM bundle (+ the new hooks, debounce-config, debugger subpath)
  • bun audit — 0 vulnerabilities
  • playground builds — astro check 0 errors, 3 pages incl. /scroll-trigger
  • publint — clean
  • attw — canonical ESM-only profile (bundler + node16-ESM 🟢; node16-CJS resolves via dynamic import; node10 n/a, predates ESM)
  • Reviewer (@clementroche): sanity-check the playground (bun run dev), including the /scroll-trigger demo

Release

  • Publishing is gated behind npm trusted publishing — once configured on the hamo package settings, creating a GitHub Release v1.0.0 runs the full gate and publishes with provenance, no token.

Known follow-ups (post-v1 — none blocking)

All deferred items are consolidated in a single tracker: #19 (lenis decoupling, useRect coordinate space, useScrollTrigger resize-staleness).


Supersedes #11 (its three hooks are ported here, credited via Co-Authored-By). Stale Dependabot PRs #6/#7/#8 closed (the bun migration replaces that lockfile).

Types & correctness:
- pass an initial value to every useRef (fixes 4 React 19 type errors)
- give useIntersectionObserver a real lazy conditional return type, a stable
  callback ref, and number[] threshold support
- fix useLazyState previousValue (was always undefined)
- cancel pending timers on unmount in useDebouncedCallback and useResizeObserver

Packaging:
- collapse packages/react into src/ (removes the duplicate package name)
- dual ESM + CJS build via tsdown unbundle with per-file "use client" so
  useObjectFit stays usable in Server Components
- inline the resize emitter and drop nanoevents -> zero runtime dependencies
- drop the unused react-dom peer; set peer react >=18; add engines.node >=18

Tooling & tests:
- Biome-only (remove the dead ESLint config); add typecheck/lint/test scripts
- add a bun:test suite: render smoke, SSR safety, debounce leak regression
- rewrite publish CI for bun; add a CI workflow with a React 18/19 matrix
Comment thread .github/workflows/ci.yml Fixed
Comment thread .github/workflows/ci.yml Fixed
arzafran added 12 commits June 5, 2026 15:17
…e object

Folds three setState calls in the resize effect into a single atomic update,
and returns a referentially stable object (it only changes on resize instead of
on every render). Public return shape { width, height, dpr } is unchanged.
Pins GITHUB_TOKEN to `contents: read` for both jobs, resolving the CodeQL
"workflow does not contain permissions" findings (publish.yml already does this).
…lback

The private helper shadowed React's reserved useEffectEvent API name, which
misleads readers and tooling into assuming that hook's call-site restrictions
apply. It is just a ref-backed stable-identity callback with none of them.
Behavior-preserving: private function, internal call sites only.
…RCE)

happy-dom <20 has a critical advisory (GHSA-37j7-fg3j-429f) plus two highs.
Test-only dependency; all 46 tests pass on 20.10.2.
Upgrade the (non-published) playground stack to patched majors and override a
transitive:
- astro ^4.16.1 -> ^6.4.4 (high reflected-XSS GHSA-wrwg-2hg8-v723 + several
  moderate/low advisories; pulls vite/esbuild to patched)
- @astrojs/react ^3.6.2 -> ^5.0.7, @astrojs/check ^0.9.3 -> ^0.9.9
- override yaml ^2.9.0 (transitive <2.8.3 stack-overflow GHSA-48c2-rrv3-qjmp)

bun audit now reports no vulnerabilities. Shipped library unaffected: 46 tests
pass, typecheck + build clean.
The playground build (astro check && astro build, not in CI) had been broken
independently of any published code:
- hamo was pulled from the registry (typeless 0.3.2) instead of the local
  build, because the repo root isn't a member of its own workspaces. Link it
  with file:.. so it resolves to the local dist (1.0.0, with types).
- Align playground on React 19 to match hamo's build types (fixes ReactNode
  version-skew errors from @astrojs/react 5 pulling @types/react 19).
- Remove www/pages/core.astro: dead page copied from another project that
  imported a non-existent ~/core/ dir and a different library (tempus).

astro check: 0 errors; astro build: 2 pages. Shipped library untouched.
Completes the green-build fix (prior commit carried only the core.astro
removal). Link hamo via file:.. so it resolves to the local dist instead of
the typeless published 0.3.2, and bump react/react-dom/@types to ^19 to match
hamo's build types (clears the @astrojs/react 5 ReactNode version skew).
Nuclear-review of playground deps:
- Remove lorem-ipsum: unused since core.astro was deleted (no remaining
  references anywhere).
- typescript ^5.5.4 -> ^6.0.3: was a major behind; @astrojs/check 0.9.9
  declares typescript ^5 || ^6, so astro check is compatible.
- @types/react refreshed to latest 19.x patch via lockfile.

astro/@astrojs/react/@astrojs/check/react/react-dom already at latest.
astro check: 0 errors; build: 2 pages. Shipped library manifest unchanged.
Move the publishable hamo library off the repo root into packages/hamo so it is
a real workspace member. The root is now a private workspace root whose
build/typecheck/lint/test/dev scripts delegate via `bun --filter hamo`.

- playground links the library with workspace:* (the file:.. hack is gone; it
  had been silently resolving the published hamo@0.3.2 from npm, without types).
- CI: test runs via `bun run test` so the package's bunfig.toml preload loads;
  React-18 matrix pin targets the package with --cwd; publish runs from
  packages/hamo (cd packages/hamo && npm publish).
- biome vcs.useIgnoreFile off (gitignore stays at root); tsconfig/biome excludes
  trimmed to package scope.

Verified: shipped tarball byte-identical to pre-move (npm pack --dry-run vs
baseline), 46 tests pass, typecheck/lint/build clean, playground builds.
Legacy sandbox whose App.jsx imported ../src/hooks/* (a path that never existed);
already excluded from lint + typecheck, no live references.
#13)

Co-authored-by: Clément Roche <rchclement@gmail.com>
@arzafran arzafran requested a review from clementroche June 8, 2026 19:28
arzafran added 5 commits June 8, 2026 17:01
Behavior-preserving maintainability pass; typecheck/lint/test/build all green.

- useScrollTrigger: delete the always-true isNumber guard and collapse the four
  repeated position-parsing blocks into one resolveAnchor helper; drop the stale
  redundant debugRef; fix debug-effect deps (biome hooks-lint 9 warnings -> 0);
  un-export the internal modulo.
- useTransform: remove structuredClone from getTransform (ran every scroll tick)
  in favour of direct accumulation; createTransform factory for inits; stabilise
  the parent-inherit subscription.
- debounce-config: extract the triplicated module-level debounce default + setter
  (useRect/useResizeObserver/useWindowSize) into one factory; per-hook semantics
  preserved.
- useEffectEvent: canonical useInsertionEffect + useCallback ponyfill; route the
  hand-rolled callbackRef pattern in four hooks through it; tighten the generic
  off any.
- useResizeObserver: null the shared observer after disconnect so a later observe
  re-creates it.
- types: deps: any[] -> DependencyList; drop {} as Rect casts; useDebouncedCallback
  preserves arg tuples; export DebouncedFunction from the barrel.
- move yaml/esbuild overrides to the workspace root so bun honours them
  (the yaml override sat in packages/hamo where it was ignored); bump
  playground astro ^6.4.4 -> ^6.4.8. bun audit: 0 vulnerabilities (was
  1 high / 2 moderate / 1 low, all in the non-published playground).
- drop the now-dead overrides block from the published package manifest.
- CHANGELOG: document the new useScrollTrigger/useTransform/useEffectEvent
  hooks and the optional lenis peer; fix stale "single src/ tree" wording.

Published artifact unchanged except CHANGELOG. hamo gates green
(typecheck/test/lint/build), playground builds 3 pages, publint clean.
- tsdown sourcemap: false. The output is modern, unminified, tree-shaken
  es2022 and tiny, so maps only bloated the tarball. Published package:
  65.6 kB -> 31.1 kB packed (-53%), 268 kB -> 119 kB unpacked, 95 -> 63 files.
- typescript ^5.4.5 -> ^6.0.3, matching the playground and removing the
  cross-workspace version skew.

Gates green (typecheck/test/lint/build), publint clean.
Drop NODE_AUTH_TOKEN/NPM_TOKEN; authenticate via the id-token OIDC token
and the trusted publisher configured on the hamo npm package. Upgrade npm
to latest on the runner (trusted publishing needs npm >= 11.5.1; node 20
ships npm 10). Provenance still attached via --provenance.
0.3.2 shipped ESM-only; the dual ESM+CJS build was added in this branch for
hypothetical CJS consumers that cannot exist (hamo never published CJS). The
target stack — Next.js, React Router, TanStack, Vite — is ESM-native, and the
in-repo consumers (bun tests, astro playground) are ESM. Remove the speculative
surface rather than maintain it.

- tsdown format: ['esm'] only; single .mjs/.d.ts output.
- package.json: drop the `require` export conditions, .d.cts, and the legacy
  `main` CJS field; exports resolve through `module` + `exports`.
- drop now-dead scripts: publish:main (superseded by the CI release workflow)
  and postversion's redundant rebuild. Keep prepublishOnly + version:* helpers.

Package: 31.1 kB -> 16.8 kB packed (-46%; -74% from the pre-cleanup 65.6 kB),
63 -> 33 files. publint clean; attw is the canonical ESM-only profile
(bundler/node16-ESM green, node16-CJS dynamic-import, node10 n/a). Gates green.
A cross-model (Codex) review caught that the main `hamo` entry statically
re-exports useScrollTrigger, which statically imports `lenis/react`. With lenis
declared optional, a consumer importing only e.g. useWindowSize without lenis
installed could hit an unresolved `lenis/react` import in Vite dev / SSR /
non-tree-shaking paths — so "optional" was not a robust guarantee.

Make lenis a required peer (drop peerDependenciesMeta). The native-scroll
fallback inside useScrollTrigger is unchanged: it keys on whether a Lenis
*instance* is mounted at runtime, not on whether the package is installed.
Docs (README, CHANGELOG, scroll-trigger README) updated to match.

No hook logic changed. Gates green: typecheck 0, 46 tests, build + publint clean.
…asing)

- useScrollTrigger/resolveAnchor: guard the numeric branch with Number.isFinite
  so a NaN/Infinity position can't propagate into progress math (falls back to 0).
- useTransform/getTransform: deep-clone the parent's userData (guarded to the
  non-empty case, so the scroll hot path skips the clone) and shallow-merge
  self's — restoring the pre-cleanup semantics a callback could otherwise use to
  mutate a parent provider's nested userData.

Both flagged by the Codex cross-model review. Gates green: typecheck 0,
46 tests, build + publint clean.
arzafran added 2 commits June 22, 2026 09:11
Major bumps: astro ^6.4.8 -> ^7.0.0, @astrojs/react ^5 -> ^6 (pulls vite 8 +
esbuild 0.28, already covered by the root esbuild override). @astrojs/check
stays 0.9.9 (latest, astro-7 compatible).

astro check: 0 errors; build: 3 pages (index, react, scroll-trigger).
bun audit: 0. hamo gates unaffected (typecheck/test/build green). Playground is
dev-only and not in CI; astro 7 requires Node >=22.12 (local 22.22).
Reverts the astro ^7 / @astrojs/react ^6 bump (916457b). astro 7.0.0 +
@astrojs/react 6 + vite 8 produce a dev-mode React-island hydration failure
("react_jsx-runtime.js does not provide an export named 't'"): the production
build is fine, but `bun run dev` renders the islands blank — which is exactly
the playground's purpose (sanity-checking the demos).

6.4.8 is the latest stable that fully works (build + dev + live scroll-trigger
hydration, verified in-browser). Revisit astro 7 once 7.0.x / @astrojs/react
6.0.x patch the jsx-runtime interop. Tracked in #19.

bun audit: 0. astro check: 0 errors, 3 pages.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants