Skip to content

feat(react): add lifecyclePlugin and useFocusEffect hook#688

Open
orionmiz wants to merge 3 commits intomainfrom
edward_karrot/plugin-refocus
Open

feat(react): add lifecyclePlugin and useFocusEffect hook#688
orionmiz wants to merge 3 commits intomainfrom
edward_karrot/plugin-refocus

Conversation

@orionmiz
Copy link
Copy Markdown
Collaborator

Summary

  • Add internal lifecyclePlugin that detects activity focus/blur transitions via onChanged (outside React render cycle)
  • Add useFocusEffect(callback) hook for per-activity lifecycle callbacks, matching React Navigation's API pattern
  • Detection and invocation happen at the plugin layer to avoid useDeferredValue tearing issues
  • Hook handles registration/deregistration only; uses callbackRef pattern (no useCallback required by consumers)
  • All user callbacks wrapped in runSafely() for error isolation

Usage

import { useFocusEffect } from "@stackflow/react/future";

function ArticleActivity() {
  useFocusEffect(() => {
    queryClient.invalidateQueries(["article", articleId]);
    return () => { /* optional cleanup on blur */ };
  });
}

Key design decisions

  • isActive over isTop: isActive changes immediately on push, while isTop waits for exit animation
  • onInit for prevActiveActivityId: Prevents missing the first blur when onChanged hasn't fired for the initial state
  • skipInitial omitted: Staleness is delegated to the data layer (e.g. TanStack Query staleTime)

Test plan

  • Initial focus: effect fires on mount when activity is active
  • Blur cleanup: cleanup runs when another activity is pushed
  • Refocus: effect re-runs when activity returns to active after pop
  • Multiple hooks in one activity: all fire correctly
  • Unmount cleanup: cleanup runs on component unmount
  • callbackRef: latest callback used on refocus after state change
  • Pushed activity: effect fires on newly pushed activity, cleanup on pop
  • Build and typecheck pass

🤖 Generated with Claude Code

Add an internal lifecycle plugin that provides focus/blur callbacks
for activities. When an activity becomes active (initial or refocus),
registered effects run. When it loses active status, cleanups execute.

Detection and invocation happen in the plugin's onChanged hook
(outside React render cycle), while the hook only handles
registration/deregistration. This avoids useDeferredValue tearing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 30, 2026

🦋 Changeset detected

Latest commit: 9ddcbae

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

This PR includes changesets to release 1 package
Name Type
@stackflow/react Minor

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

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 457375ab-0118-4e5d-ae05-72cfa24f6fbb

📥 Commits

Reviewing files that changed from the base of the PR and between 808f841 and 9ddcbae.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (1)
  • .pnp.cjs
✅ Files skipped from review due to trivial changes (1)
  • .pnp.cjs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a lifecycle plugin and a new React hook to register per-activity focus/blur callbacks; exported lifecycle APIs for consumption
  • Tests

    • Added comprehensive tests validating focus, cleanup, and callback-update behavior across activity transitions
  • Chores

    • Added test scripts and Jest config; updated build to exclude spec files and compute entry points accordingly; refreshed Yarn Plug’n’Play runtime mappings and virtual package state

Walkthrough

Adds a React lifecycle plugin (lifecyclePlugin + useFocusEffect) that tracks activity focus/blur and runs per-activity callbacks; integrates the plugin into stack initialization. Also adds Jest tests, updates esbuild entry discovery to exclude spec files, adjusts tsconfig excludes, and updates Yarn PnP virtual mappings in .pnp.cjs.

Changes

Cohort / File(s) Summary
Yarn PnP Runtime Configuration
.pnp.cjs
Regenerated Yarn Plug’n’Play runtime state: changed virtual package ids and workspace dependency edges for @stackflow/plugin-renderer-basic and @stackflow/react, and updated virtual ids for transitive packages while retaining npm versions.
Build Configuration
integrations/react/esbuild.config.js, integrations/react/tsconfig.json
esbuild entryPoints now computed by scanning ./src recursively and excluding .spec. files; tsconfig excludes spec files from compilation.
Test Setup & Dev Dependencies
integrations/react/package.json
Added Jest config and test script; added jest/jsdom/@swc and Testing Library dev deps, plus react-dom dev dep.
Lifecycle Plugin Core
integrations/react/src/future/lifecycle/lifecyclePlugin.tsx, integrations/react/src/future/lifecycle/runSafely.ts, integrations/react/src/future/lifecycle/useFocusEffect.ts, integrations/react/src/future/lifecycle/index.ts
New lifecycle store, context, and plugin implementation that detects active-activity transitions, runs/cleans focus effects, and exposes useLifecycleStore; added runSafely utility and useFocusEffect hook to register per-activity focus callbacks.
Integration into Stackflow
integrations/react/src/future/stackflow.tsx
Inserted lifecyclePlugin() into the plugins array before user plugins so lifecycle handling runs during stack changes.
Public Re-exports
integrations/react/src/future/index.ts, integrations/react/src/future/lifecycle/index.ts
Re-exported useFocusEffect and lifecyclePlugin to expose the new lifecycle API surface.
Tests
integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx
Added comprehensive tests exercising focus/blur behavior, cleanup, multiple effects per activity, callback updates, and unmount handling.
Changeset
.changeset/add-lifecycle-plugin.md
Added changeset documenting a minor version bump for @stackflow/react describing lifecycle plugin and useFocusEffect addition.

Sequence Diagram

sequenceDiagram
    actor ActivityComponent
    participant LifecyclePlugin
    participant StackflowCore
    participant EffectRegistry

    ActivityComponent->>LifecyclePlugin: useFocusEffect(callback) — register entry
    LifecyclePlugin->>EffectRegistry: store {symbol, activityId, callbackRef}
    
    StackflowCore->>LifecyclePlugin: onChanged(newActiveActivityId)
    LifecyclePlugin->>EffectRegistry: detect prevActive -> newActive
    LifecyclePlugin->>EffectRegistry: blur: run stored cleanup for prev activity
    LifecyclePlugin->>EffectRegistry: focus: invoke callbackRef.current for new activity
    EffectRegistry-->>LifecyclePlugin: returns optional cleanup
    LifecyclePlugin->>EffectRegistry: store returned cleanup
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main feature additions: a lifecyclePlugin and useFocusEffect hook in the React integration.
Description check ✅ Passed The description is well-related to the changeset, providing clear context about the lifecycle plugin implementation, design decisions, usage examples, and test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch edward_karrot/plugin-refocus

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 30, 2026

Deploying stackflow-demo with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9ddcbae
Status: ✅  Deploy successful!
Preview URL: https://73eaa740.stackflow-demo.pages.dev
Branch Preview URL: https://edward-karrot-plugin-refocus.stackflow-demo.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 30, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
stackflow-docs 9ddcbae Commit Preview URL Mar 30 2026, 10:29 AM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 30, 2026

commit: 9ddcbae

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx (1)

208-251: Consider adding a null guard or explicit assertion for setUseSecond.

The definite assignment assertion (!:) on line 212 assumes setUseSecond will be assigned before use. While this works in practice because render() synchronously mounts the component, it bypasses TypeScript's null safety.

A safer pattern would be to assert assignment or use a ref container:

♻️ Optional: Add explicit assertion for clarity
-      let setUseSecond!: (v: boolean) => void;
+      let setUseSecond: ((v: boolean) => void) | undefined;

       function ActivityA() {
         const [useSecond, _setUseSecond] = useState(false);
         setUseSecond = _setUseSecond;
         
         useFocusEffect(useSecond ? secondEffect : firstEffect);
         return <div>A</div>;
       }
       // ... after render ...
       
+      expect(setUseSecond).toBeDefined();
+      
       // Update callback while A is active
       await act(async () => {
-        setUseSecond(true);
+        setUseSecond!(true);
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx` around
lines 208 - 251, The test currently uses a definite assignment for setUseSecond
in ActivityA which can be undefined; add a null guard or explicit runtime
assertion before calling it in the test (or initialize it to a safe stub) so we
don't rely on TypeScript's definite assignment. Locate the test's setUseSecond
variable and either (a) initialize it to a no-op or throwing stub at
declaration, (b) add an assertion like expect(setUseSecond).toBeDefined() before
calling setUseSecond(true), or (c) switch to a ref container inside ActivityA
and expose a stable setter; update references in this spec where setUseSecond is
invoked to use the chosen safe pattern (e.g., the actions inside act blocks).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx`:
- Around line 208-251: The test currently uses a definite assignment for
setUseSecond in ActivityA which can be undefined; add a null guard or explicit
runtime assertion before calling it in the test (or initialize it to a safe
stub) so we don't rely on TypeScript's definite assignment. Locate the test's
setUseSecond variable and either (a) initialize it to a no-op or throwing stub
at declaration, (b) add an assertion like expect(setUseSecond).toBeDefined()
before calling setUseSecond(true), or (c) switch to a ref container inside
ActivityA and expose a stable setter; update references in this spec where
setUseSecond is invoked to use the chosen safe pattern (e.g., the actions inside
act blocks).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1f7824fd-2f3e-4145-b6f4-e9427087570a

📥 Commits

Reviewing files that changed from the base of the PR and between c92a1c4 and bd1776b.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (11)
  • .pnp.cjs
  • integrations/react/esbuild.config.js
  • integrations/react/package.json
  • integrations/react/src/future/index.ts
  • integrations/react/src/future/lifecycle/index.ts
  • integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx
  • integrations/react/src/future/lifecycle/lifecyclePlugin.tsx
  • integrations/react/src/future/lifecycle/runSafely.ts
  • integrations/react/src/future/lifecycle/useFocusEffect.ts
  • integrations/react/src/future/stackflow.tsx
  • integrations/react/tsconfig.json

orionmiz and others added 2 commits March 30, 2026 17:02
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

1 participant