Skip to content

feat: v1.0.0 hardening#12

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

feat: v1.0.0 hardening#12
arzafran wants to merge 15 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. Four things landed on this branch:

  1. Quality gating + hardening — the package didn't type-check, CI couldn't publish, the hooks-lint config was dead, and one README was actually Lenis's. This wires up the checks and fixes what they surface, across Next.js, React Router, TanStack, Vite, and CJS/Jest consumers.
  2. Security — cleared a critical RCE in the test toolchain and every outstanding Dependabot advisory.
  3. Monorepo restructure + new scroll hooks — moved the library into packages/hamo/, and brought in three new hooks ported from feat: useScrollTrigger, useTransform, useEffectEvent #11 (credit @clementroche).
  4. Maintainability pass — a whole-codebase review that deletes a broken guard, a per-frame deep clone on the scroll hot path, and three copies of the same debounce config. No behavior change; it's the diff getting smaller and harder to misread.

No existing public API 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 isNumber guard that was always true — every keyword slipped through and only the follow-up ifs rescued the result, leaving the ternary fallbacks as dead code. The four repeated position-parsing blocks collapse into one resolveAnchor helper. Also dropped a stale, redundant debugRef, corrected the debug-effect dependency arrays (Biome hooks-lint now reports 0 warnings, down from 9), and 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, and the parent-inherit subscription is now a stable identity so it isn't torn down and recreated every render.
  • Shared debounce configuseRect, useResizeObserver, and useWindowSize each carried a verbatim copy of the module-level debounce default + setDebounce. Extracted to a single 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 (no render-phase ref mutation); the hand-rolled callbackRef pattern that four hooks reimplemented now routes through it. Generic constraint tightened off any.
  • useResizeObserver: the shared observer is reset to null after disconnect(), so a later observe() re-creates it instead of reusing a disconnected instance.
  • Type tightening: deps: any[]DependencyList across hooks, removed the {} as Rect casts, useDebouncedCallback now preserves argument tuples, and DebouncedFunction is exported from the barrel.
  • Known follow-up (not in this PR): useRect's scrollTop/scrollLeft helpers accumulate scroll past the intended wrapper boundary up to the document root. Left as-is pending a coordinate-space decision — it's tested behavior and changing it blindly is riskier than the cleanup is worth.

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 shipped behind the hamo/scroll-trigger/debugger subpath.
  • useTransform / TransformProvider — context-based transform accumulation for parallax compensation.
  • useEffectEvent — promoted to a public hook; use-debounce now consumes it (removed the private useStableCallback duplicate). v1's debounce implementation was kept (it had the unmount-cleanup the feat: useScrollTrigger, useTransform, useEffectEvent #11 branch lacked).

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 (astro 4→6, vite/esbuild/yaml; dropped a dead lorem-ipsum) — bun audit reports 0 vulnerabilities. The playground is not published, so none of this reaches consumers.

Packaging / structure

  • Library now lives in packages/hamo/; playground/ is a sibling workspace that links it via workspace:* (a real monorepo — 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.
  • Dual ESM + CJS build via tsdown unbundle, per-file "use client" so useObjectFit stays usable in Server Components.
  • Zero runtime dependencies — both the resize and scroll-trigger store emitters are inlined (no nanoevents). Peers: react >=18, plus lenis as an optional peer (only needed for useScrollTrigger; the hook falls back to native scroll without it).
  • Removed the dead legacy docs/ directory; engines.node >=18.

Tooling & tests

  • Single toolchain: Biome 2 (rules-of-hooks useHookAtTopLevel + useExhaustiveDependencies), 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 (publish runs from the package; test routes through the package's bunfig.toml preload); least-privilege permissions; lockfile committed.

Test Plan

  • bun run typecheck — 0 errors
  • bun run test — 46 pass / 0 fail
  • bun run lint (biome) — clean, 0 warnings (down from 9)
  • bun run build — emits the dual ESM/CJS bundle (+ the new hooks, the debounce-config module & debugger subpath)
  • bun audit — 0 vulnerabilities
  • playground builds — astro check 0 errors, 3 pages incl. /scroll-trigger
  • publint — clean
  • attw — main entry + all hooks resolve 🟢 across node16 + bundler. Known limitation: the dev-only hamo/scroll-trigger/debugger subpath does not resolve under legacy node10 module resolution (it reads main, not exports). Modern tooling is unaffected; acceptable for a debug-only export.
  • Reviewer (@clementroche): sanity-check the playground (bun run dev), including the /scroll-trigger demo

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
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.
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