Skip to content

[WB-2281] Add useAnimationFrame and useActionScheduler hooks to wonder-blocks-timing#2991

Open
tony-dinh wants to merge 7 commits intomainfrom
WB-2281
Open

[WB-2281] Add useAnimationFrame and useActionScheduler hooks to wonder-blocks-timing#2991
tony-dinh wants to merge 7 commits intomainfrom
WB-2281

Conversation

@tony-dinh
Copy link
Copy Markdown
Contributor

@tony-dinh tony-dinh commented Mar 25, 2026

Summary:

This PR adds two new hooks to @khanacademy/wonder-blocks-timing that complete the hook-based API for the timing package.

  • Adds useAnimationFrame: a hook wrapping requestAnimationFrame/cancelAnimationFrame that follows the same proxy-ref and policy pattern as the existing useTimeout and useInterval hooks — supports SchedulePolicy, ClearPolicy, and ActionPolicy
  • Adds useActionScheduler: a hook-based alternative to the withActionScheduler HOC that returns a stable IScheduleActions API and automatically calls disable() on unmount, allowing components to schedule timeouts, intervals, and animation frames without needing to wrap in a HOC
  • Exports both hooks from the package's public index

Issue: https://khanacademy.atlassian.net/browse/WB-2281

Test plan:

  • Run pnpm jest packages/wonder-blocks-timing --no-coverage --no-watchman

…r-blocks-timing

This PR adds two new hooks to `@khanacademy/wonder-blocks-timing` that complete the hook-based API for the timing package.

- Adds `useAnimationFrame`: a hook wrapping `requestAnimationFrame`/`cancelAnimationFrame` that follows the same proxy-ref and policy pattern as the existing `useTimeout` and `useInterval` hooks — supports `SchedulePolicy`, `ClearPolicy`, and `ActionPolicy`
- Adds `useActionScheduler`: a hook-based alternative to the `withActionScheduler` HOC that returns a stable `IScheduleActions` API and automatically calls `disable()` on unmount, allowing components to schedule timeouts, intervals, and animation frames without needing to wrap in a HOC
- Exports both hooks from the package's public index

Note: animation frame tests require `cleanup()` to be called explicitly before `jest.restoreAllMocks()` in `afterEach` — RTL's auto-cleanup runs after the describe-level `afterEach`, so restoring the `cancelAnimationFrame` spy first causes sinon's fake timer to throw on component unmount.

Issue: https://khanacademy.atlassian.net/browse/WB-2281

Test plan:

- [ ] Run `pnpm jest packages/wonder-blocks-timing --no-coverage --no-watchman`
@tony-dinh tony-dinh self-assigned this Mar 25, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 25, 2026

🦋 Changeset detected

Latest commit: 694acbe

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
@khanacademy/wonder-blocks-timing Minor
@khanacademy/wonder-blocks-dropdown Patch
@khanacademy/wonder-blocks-modal Patch
@khanacademy/wonder-blocks-birthday-picker Patch
@khanacademy/wonder-blocks-tabs Patch
@khanacademy/wonder-blocks-date-picker Patch
@khanacademy/wonder-blocks-popover Patch
@khanacademy/wonder-blocks-tooltip Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 25, 2026

Size Change: +158 B (+0.13%)

Total Size: 121 kB

📦 View Changed
Filename Size Change
packages/wonder-blocks-timing/dist/es/index.js 1.53 kB +158 B (+11.51%) ⚠️
ℹ️ View Unchanged
Filename Size
packages/wonder-blocks-accordion/dist/es/index.js 3 kB
packages/wonder-blocks-announcer/dist/es/index.js 2.43 kB
packages/wonder-blocks-badge/dist/es/index.js 2.02 kB
packages/wonder-blocks-banner/dist/es/index.js 2.01 kB
packages/wonder-blocks-birthday-picker/dist/es/index.js 1.91 kB
packages/wonder-blocks-breadcrumbs/dist/es/index.js 755 B
packages/wonder-blocks-button/dist/es/index.js 4.28 kB
packages/wonder-blocks-card/dist/es/index.js 1.08 kB
packages/wonder-blocks-cell/dist/es/index.js 2.18 kB
packages/wonder-blocks-clickable/dist/es/index.js 2.6 kB
packages/wonder-blocks-core/dist/es/index.js 2.59 kB
packages/wonder-blocks-data/dist/es/index.js 5.48 kB
packages/wonder-blocks-date-picker/dist/es/index.js 8.06 kB
packages/wonder-blocks-dropdown/dist/es/index.js 19.7 kB
packages/wonder-blocks-form/dist/es/index.js 6.3 kB
packages/wonder-blocks-grid/dist/es/index.js 1.24 kB
packages/wonder-blocks-icon-button/dist/es/index.js 4.01 kB
packages/wonder-blocks-icon/dist/es/index.js 1.91 kB
packages/wonder-blocks-labeled-field/dist/es/index.js 3.47 kB
packages/wonder-blocks-layout/dist/es/index.js 1.63 kB
packages/wonder-blocks-link/dist/es/index.js 1.53 kB
packages/wonder-blocks-modal/dist/es/index.js 7.36 kB
packages/wonder-blocks-pill/dist/es/index.js 1.31 kB
packages/wonder-blocks-popover/dist/es/index.js 4.32 kB
packages/wonder-blocks-progress-spinner/dist/es/index.js 1.48 kB
packages/wonder-blocks-search-field/dist/es/index.js 1.1 kB
packages/wonder-blocks-styles/dist/es/index.js 464 B
packages/wonder-blocks-switch/dist/es/index.js 1.55 kB
packages/wonder-blocks-tabs/dist/es/index.js 5.57 kB
packages/wonder-blocks-testing-core/dist/es/index.js 3.25 kB
packages/wonder-blocks-testing/dist/es/index.js 978 B
packages/wonder-blocks-theming/dist/es/index.js 384 B
packages/wonder-blocks-tokens/dist/es/index.js 5.18 kB
packages/wonder-blocks-toolbar/dist/es/index.js 906 B
packages/wonder-blocks-tooltip/dist/es/index.js 6.02 kB
packages/wonder-blocks-typography/dist/es/index.js 1.57 kB

compressed-size-action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 25, 2026

npm Snapshot: Published

🎉 Good news!! We've packaged up the latest commit from this PR (2d76160) and published all packages with changesets to npm.

You can install the packages in frontend by running:

./dev/tools/deploy_wonder_blocks.js --tag="PR2991"

Packages can also be installed manually by running:

pnpm add @khanacademy/wonder-blocks-<package-name>@PR2991

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 25, 2026

A new build was pushed to Chromatic! 🚀

https://5e1bf4b385e3fb0020b7073c-cfnffgvhgi.chromatic.com/

Chromatic results:

Metric Total
Captured snapshots 417
Tests with visual changes 0
Total stories 835
Inherited (not captured) snapshots [TurboSnap] 41
Tests on the build 458

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

@tony-dinh tony-dinh marked this pull request as ready for review March 25, 2026 23:56
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review.

Tip: disable this comment in your organization's Code Review settings.

@khan-actions-bot khan-actions-bot requested a review from a team March 25, 2026 23:56
@khan-actions-bot
Copy link
Copy Markdown
Contributor

khan-actions-bot commented Mar 25, 2026

Gerald

Required Reviewers
  • @Khan/wonder-blocks for changes to .changeset/real-teachers-draw.md, __docs__/wonder-blocks-timing/use-action-scheduler.mdx, __docs__/wonder-blocks-timing/use-action-scheduler.stories.tsx, __docs__/wonder-blocks-timing/use-animation-frame.mdx, __docs__/wonder-blocks-timing/use-animation-frame.stories.tsx, packages/wonder-blocks-timing/src/index.ts, packages/wonder-blocks-timing/src/hooks/use-action-scheduler.ts, packages/wonder-blocks-timing/src/hooks/use-animation-frame.ts, packages/wonder-blocks-timing/src/hooks/use-interval.ts, packages/wonder-blocks-timing/src/hooks/use-timeout.ts, packages/wonder-blocks-timing/src/util/types.ts, packages/wonder-blocks-timing/src/hooks/__tests__/use-action-scheduler.test.ts, packages/wonder-blocks-timing/src/hooks/__tests__/use-animation-frame.test.ts

Don't want to be involved in this pull request? Comment #removeme and we won't notify you of further changes.

@tony-dinh
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/wonder-blocks-timing/src/hooks/use-action-scheduler.ts Outdated
Comment thread packages/wonder-blocks-timing/src/hooks/use-animation-frame.ts Outdated
Comment thread __docs__/wonder-blocks-timing/use-animation-frame.mdx
Comment thread __docs__/wonder-blocks-timing/use-action-scheduler.mdx Outdated
Copy link
Copy Markdown
Member

@somewhatabstract somewhatabstract left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation looks pretty solid, but the docs seem quite wrong. They are describing behaviors that are not present (nor should be, at least for this initial version). Can you update them to be correct - it's likely you can mostly borrow from existing docs to make something consistent.

Comment thread __docs__/wonder-blocks-timing/use-animation-frame.mdx Outdated
Comment thread packages/wonder-blocks-timing/src/hooks/use-animation-frame.ts Outdated
Comment thread packages/wonder-blocks-timing/src/hooks/use-action-scheduler.ts
Comment thread __docs__/wonder-blocks-timing/use-animation-frame.stories.tsx
Comment thread __docs__/wonder-blocks-timing/use-animation-frame.mdx
@tony-dinh tony-dinh force-pushed the WB-2281 branch 2 times, most recently from e165bf8 to ba1d3e2 Compare March 26, 2026 20:15
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All previously raised issues have been addressed — LGTM.

Extended reasoning...

Overview

This PR adds useAnimationFrame and useActionScheduler hooks to wonder-blocks-timing, completing the hook-based API alongside the existing useTimeout and useInterval. Changes span two new hook implementations, comprehensive tests (~840 lines combined), Storybook docs/stories, type additions, and public exports.

Security risks

No security-sensitive code paths — purely client-side React hooks wrapping browser timer APIs (requestAnimationFrame, setTimeout, setInterval). No auth, crypto, or data exposure concerns.

Level of scrutiny

This is new public API surface for an internal component library. The implementation follows well-established patterns from the existing useTimeout and useInterval hooks. I reviewed this PR in two prior passes and raised eight issues (one red, seven yellow). The author addressed all of them in the subsequent "Address feedback" commits: the React 18 Strict Mode bug (ActionScheduler moved into useEffect), JSDoc inaccuracies, missing timestamp parameter in docs, broken anchor link, stale Sinon references in test comments, missing pre-mount no-op documentation, an unmount race in the animation loop story, and a misleading useTimeout/useInterval comparison. The fixes are correct and match the patterns used in the rest of the timing package.

Other factors

Test coverage is thorough — the useAnimationFrame test file has ~40 cases covering all policy combinations, proxy-ref behavior, and lifecycle edges. The useActionScheduler tests cover delegation, stability, and unmount cleanup. Chromatic shows no visual regressions. The human reviewer’s open questions were resolved in the PR thread.

Comment thread packages/wonder-blocks-timing/src/util/types.ts Outdated
Comment thread packages/wonder-blocks-timing/src/util/types.ts
Copy link
Copy Markdown
Member

@somewhatabstract somewhatabstract left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great. The docs just need some tidy-up...mostly just deleting implementation details from the type docs.

Comment thread packages/wonder-blocks-timing/src/hooks/use-animation-frame.ts Outdated
Comment thread packages/wonder-blocks-timing/src/util/types.ts Outdated
Comment thread packages/wonder-blocks-timing/src/util/types.ts Outdated
Comment thread packages/wonder-blocks-timing/src/util/types.ts Outdated
Comment thread packages/wonder-blocks-timing/src/util/types.ts Outdated
Comment thread __docs__/wonder-blocks-timing/use-animation-frame.stories.tsx
Comment thread packages/wonder-blocks-timing/src/hooks/use-timeout.ts
Comment on lines +47 to +52
if (action !== actionProxyRef.current) {
actionProxyRef.current = action;
if (actionPolicy === ActionPolicy.Reset) {
animationFrameRef.current?.set();
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 This is a pre-existing issue being replicated from the sibling useTimeout and useInterval hooks: animationFrameRef.current?.set() is called directly in the render body when actionPolicy === ActionPolicy.Reset (lines 47-52), which is technically a side effect during the React render phase. The same pattern exists verbatim in use-timeout.ts and use-interval.ts, so fixing it here would require fixing all three hooks consistently.

Extended reasoning...

What the bug is

In use-animation-frame.ts lines 47-52, when actionPolicy === ActionPolicy.Reset and action !== actionProxyRef.current, the hook calls animationFrameRef.current?.set() directly in the component render body — not inside a useEffect. React considers side effects during render incorrect, particularly in Concurrent Mode, where the render function can be called multiple times before committing.

The specific code path that triggers it

The render body runs:

if (action !== actionProxyRef.current) {
    actionProxyRef.current = action;   // ref mutation
    if (actionPolicy === ActionPolicy.Reset) {
        animationFrameRef.current?.set();  // side effect: cancel + reschedule rAF
    }
}

In React Concurrent Mode, if a render is interrupted and restarted, this block can fire from a render that is never committed. The set() call invokes cancelAnimationFrame + requestAnimationFrame — real browser APIs — from the interrupted render.

Addressing the refutation

One verifier correctly notes that the ref guard provides practical idempotency: once actionProxyRef.current is updated to action2 in the first (interrupted) render, the condition action !== actionProxyRef.current evaluates false in the restarted render, so set() is not called a second time. This substantially limits the practical impact. The worst case is a single extra cancel+reschedule pair from the interrupted render, not unbounded repetition.

Additionally, because actionProxyRef.current was already updated, the rAF scheduled from the interrupted render will call the correct (new) action when it fires — so correctness is preserved even in the edge case.

Why this is pre-existing

The identical pattern appears in both use-timeout.ts (same lines range) and use-interval.ts (same lines range):

// use-timeout.ts and use-interval.ts — the same pattern
if (action !== actionProxyRef.current) {
    actionProxyRef.current = action;
    if (actionPolicy === ActionPolicy.Reset) {
        timeoutRef.current?.set();   // or intervalRef.current?.set()
    }
}

This PR introduces useAnimationFrame following the exact same established pattern. All three verifiers confirmed the pre_existing classification — fixing it in this PR alone without fixing the sibling hooks would be inconsistent.

Practical impact and fix

In practice, the impact is low: the idempotency guard prevents double-scheduling, and the worst outcome is one extra cancel+reschedule from an interrupted render. The correct fix — if the team decides to address it — is to move the animationFrameRef.current?.set() call into a useEffect with [action, actionPolicy] dependencies, and apply the same fix symmetrically to useTimeout and useInterval. The fix in this PR should be part of a broader cleanup of all three hooks together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants