Skip to content

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

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

feat(react): add lifecyclePlugin and useFocusEffect hook#688
orionmiz wants to merge 6 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: a416012

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: 0c79396f-a775-41e8-93e5-8ee6057cc386

📥 Commits

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

📒 Files selected for processing (1)
  • integrations/react/src/stable/useActiveEffect.ts
✅ Files skipped from review due to trivial changes (1)
  • integrations/react/src/stable/useActiveEffect.ts

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a lifecycle plugin and a new React hook to register per-activity focus/blur callbacks; lifecycle behavior is now handled reliably across activity transitions.
  • Tests

    • Added comprehensive tests validating focus invocation, cleanup, and callback-update behavior during push/pop and unmount sequences.
  • Chores

    • Added test scripts and Jest config; build config now excludes spec files and computes entry points; updated package runtime state and release changeset.

Walkthrough

Adds a React lifecycle plugin (lifecyclePlugin + useFocusEffect) that registers per-activity focus/blur callbacks and runs their effects on activity transitions; integrates the plugin into stack initialization, adds tests, updates esbuild entry discovery to exclude spec files, adjusts tsconfig, and updates Yarn PnP runtime mappings.

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 / TS Config
integrations/react/esbuild.config.js, integrations/react/tsconfig.json
esbuild now computes entryPoints by scanning ./src recursively and excludes files containing .spec.; tsconfig excludes spec files from compilation.
Test Setup & Dev Dependencies
integrations/react/package.json
Added test script and Jest config; added Jest/JS DOM/@swc and Testing Library dev dependencies and react-dom devDependency.
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
Introduced lifecycle store/context, lifecyclePlugin() implementation that detects active-activity transitions and runs/cleans focus effects, runSafely utility, and useFocusEffect hook for per-activity focus callbacks.
Integration into Stackflow
integrations/react/src/future/stackflow.tsx
Inserted lifecyclePlugin() into the default plugins array before user-provided plugins.
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 tests exercising focus/blur invocation, cleanup, multiple effects per activity, callback updates, and unmount behavior.
Deprecation Note
integrations/react/src/stable/useActiveEffect.ts
Marked useActiveEffect as deprecated via JSDoc, pointing to useFocusEffect.
Changeset
.changeset/add-lifecycle-plugin.md
Added changeset bumping @stackflow/react minor version documenting the lifecycle plugin and useFocusEffect.

Sequence Diagram

sequenceDiagram
    actor ActivityComponent
    participant LifecyclePlugin as "LifecyclePlugin (rgba(100,150,240,0.5))"
    participant StackflowCore as "StackflowCore (rgba(100,200,150,0.5))"
    participant EffectRegistry as "EffectRegistry (rgba(240,150,100,0.5))"

    ActivityComponent->>LifecyclePlugin: call 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 'feat(react): add lifecyclePlugin and useFocusEffect hook' accurately and concisely describes the main additions in this pull request.
Description check ✅ Passed The description comprehensively covers the changeset, explaining the new lifecycle plugin, useFocusEffect hook, design decisions, usage example, 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: a416012
Status: ✅  Deploy successful!
Preview URL: https://0afdc454.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 a416012 Commit Preview URL Apr 01 2026, 10:15 AM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 30, 2026

commit: a416012

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

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