Skip to content

Conversation

@parkwoocheol
Copy link

Summary

This PR introduces the structuredStackflow API, a new way to define and manage activities hierarchically, similar to Jetpack Compose Navigation.

Motivation

As applications grow, managing a flat list of activities becomes difficult. This new API allows developers to group related activities (e.g., Auth, Settings) using defineNavigation, improving code organization and maintainability.

Features

  • Hierarchical Definition: Define activities in a nested structure using defineNavigation, allowing logical grouping of related screens.
  • Type Safety: Fully typed API that correctly infers parameters even from deeply nested activity structures.
  • Safety Checks: Built-in runtime validation to ensure unique activity names across the entire hierarchy.
  • Plugin Integration: Includes createRoutesFromActivities helper to easily integrate with plugins like historySyncPlugin without manual route duplication.

Usage Example

import { defineActivity, defineNavigation, structuredStackflow, createRoutesFromActivities } from "@stackflow/react/future";
import { historySyncPlugin } from "@stackflow/plugin-history-sync";

// 1. Define Activities Hierarchically
const activities = {
  // Simple top-level activity
  Main: defineActivity("Main")({ 
    component: MainPage, 
    route: "/" 
  }),
  
  // Grouped activities (e.g., Authentication flow)
  Auth: defineNavigation("Auth")({
    initialActivity: "Login",
    activities: {
      Login: defineActivity("Login")({ 
        component: LoginPage, 
        route: "/auth/login",
      }),
      Register: defineActivity("Register")({ 
        component: RegisterPage, 
        route: "/auth/register" 
      }),
    },
  }),
};

// 2. Create Stack with DRY Routes
export const { Stack, useFlow } = structuredStackflow({
  activities,
  initialActivity: "Main", // Type-safe!
  plugins: [
    historySyncPlugin({
      // Automatically extracts routes from the nested structure
      routes: createRoutesFromActivities(activities),
      fallbackActivity: () => "Main",
    }),
  ],
});

// 3. Type-Safe Navigation
function MyComponent() {
  const { push } = useFlow();
  
  const handleLogin = () => {
    // TypeScript knows 'Login' exists and what params it takes
    push("Login", { 
      redirect: "/dashboard" 
    }); 
  };
}

- Introduce structuredStackflow for hierarchical activity management
- Support nested navigation with defineNavigation
- Provide type-safe useFlow hook for structured activities
- Include built-in route extraction helper createRoutesFromActivities
@changeset-bot
Copy link

changeset-bot bot commented Nov 24, 2025

⚠️ No Changeset found

Latest commit: 69c4de6

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@coderabbitai
Copy link

coderabbitai bot commented Nov 24, 2025

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Introduces declarative activity and navigation definitions with full type safety.
    • Adds structured stack navigation system supporting push, replace, and pop operations with transition control.
    • Provides hooks and utilities for type-safe navigation actions and route generation.

✏️ Tip: You can customize this high-level summary in your review settings.

Walkthrough

Adds a typed activity/navigation DSL and a structured stack navigation implementation for React: new factories to define activities and navigations, a structuredStackflow factory that builds a stack-based navigator with typed actions, data loaders with caching, plugin integration, and route generation utilities.

Changes

Cohort / File(s) Summary
Activity & Navigation Definitions
integrations/react/src/future/defineActivity.ts
New module: adds ActivityRoute, ActivityLoader, ActivityDefinitionInput/Output, defineActivity factory, DestinationsMap, NavigationDefinitionOutput, defineNavigation factory, and isNavigationDefinition type guard.
Structured Stack Navigation
integrations/react/src/future/structuredStackflow.tsx
New module: implements structuredStackflow factory and related types (ActivitiesMap, AllActivityNames, GetActivityDefinition, InferActivityParamsFromMap, TypedActions, TypedStepActions, StructuredStackflowInput/Output), activity flattening, createRoutesFromActivities, loader data cache with eviction, core store initialization, plugin wiring, navigation actions (push/replace/pop and step variants), and a Stack React component with provider wiring.
Public API Exports
integrations/react/src/future/index.ts
Re-exports added: export * from "./defineActivity" and export * from "./structuredStackflow" to expose the new APIs.

Sequence Diagram(s)

sequenceDiagram
    participant Dev as Developer
    participant defineActivity as defineActivity<br/>(Factory)
    participant defineNavigation as defineNavigation<br/>(Factory)
    participant structuredStackflow as structuredStackflow<br/>(Factory)
    participant Stack as Stack Component
    participant CoreStore as CoreStore +<br/>Plugins

    Dev->>defineActivity: defineActivity("screen1")
    defineActivity-->>Dev: returns function
    Dev->>defineActivity: pass ActivityDefinitionInput<br/>(component, route, loader, transition)
    defineActivity-->>Dev: ActivityDefinitionOutput<"screen1", TParams>

    Dev->>defineNavigation: defineNavigation("root")
    defineNavigation-->>Dev: returns function
    Dev->>defineNavigation: pass {activities, initialActivity}
    defineNavigation-->>Dev: NavigationDefinitionOutput

    Dev->>structuredStackflow: pass StructuredStackflowInput<br/>(activities, initialActivity, plugins)
    structuredStackflow->>structuredStackflow: flattenActivities()<br/>createRoutesFromActivities()
    structuredStackflow->>structuredStackflow: setup loaderDataCacheMap<br/>initialize CoreStore
    structuredStackflow->>structuredStackflow: register plugins<br/>(including loader plugin)
    structuredStackflow-->>Dev: StructuredStackflowOutput<br/>(Stack, actions, hooks)

    Dev->>Stack: render Stack component<br/>with ConfigProvider, PluginsProvider, CoreProvider
    Stack->>CoreStore: dispatch Initialized<br/>ActivityRegistered events
    Stack->>Stack: render current activity<br/>via ActivityComponentMapProvider + DataLoaderProvider
    Stack-->>Dev: rendered navigation UI
Loading
sequenceDiagram
    participant User as User
    participant TypedActions as TypedActions<br/>(actions.push/replace)
    participant LoaderPlugin as Loader Plugin
    participant DataCache as LoaderDataCacheMap
    participant Renderer as Stack Renderer

    User->>TypedActions: actions.push("nextScreen", params, options)
    TypedActions->>LoaderPlugin: trigger loader plugin
    LoaderPlugin->>DataCache: check cache by param equality
    alt Cache Hit
        DataCache-->>LoaderPlugin: cached data
    else Cache Miss
        LoaderPlugin->>LoaderPlugin: invoke loader(params, config)
        LoaderPlugin->>DataCache: store result + timestamp
    end
    LoaderPlugin-->>TypedActions: data ready
    TypedActions->>Renderer: dispatch state update<br/>(new activity, transition animation)
    Renderer->>Renderer: render new activity component<br/>with loaded data
    Renderer-->>User: activity displayed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45–75 minutes

Areas to review closely:

  • Type inference utilities and recursive generic types in structuredStackflow.tsx (name resolution, deep nesting, collisions).
  • Loader cache logic: param equality, cache key correctness, eviction timing, and cleanup.
  • Core store initialization and browser vs. non-browser handling (SSR/hydration implications).
  • Plugin registration/wiring and lifecycle (especially loader plugin interactions).
  • Activity flattening and createRoutesFromActivities correctness for nested navigations and duplicate detection.
  • Provider composition and context propagation in the Stack component.

Pre-merge checks and finishing touches

❌ 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%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(react/future): add structuredStackflow API' directly and clearly describes the main addition in this PR—a new structuredStackflow API for hierarchical activity management.
Description check ✅ Passed The description comprehensively explains the structuredStackflow API, its motivation, features, and includes practical usage examples that align with the code changes introduced.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

Copy link

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
integrations/react/src/future/defineActivity.ts (1)

50-79: Consider tightening DestinationsMap typing to reduce any usage

DestinationsMap and isNavigationDefinition currently rely on ActivityDefinitionOutput<string, any> | NavigationDefinitionOutput<string, any>, which weakens type safety at the boundary. You could consider:

  • Switching any to unknown where possible, and
  • Threading more specific generics through DestinationsMap / NavigationDefinitionOutput so downstream consumers don’t have to immediately cast back to any.

This would keep the runtime behavior the same while providing stricter compile-time guarantees.

integrations/react/src/future/structuredStackflow.tsx (2)

171-239: Loader caching is solid; consider tightening typing and simplifying the error path

The per-activity cache keyed by deep-equal params and the “max age after resolution” behavior are well thought out. A couple of small improvements:

  • Typing: instead of (activityConfig.loader as any)?.loaderCacheMaxAge, consider extending the loader type:
export type ActivityLoader<TParams> = (args: {...}) => unknown & {
  loaderCacheMaxAge?: number;
};

(or an intersection type) so loaderCacheMaxAge is discoverable and type-safe.

  • Error handler: in
Promise.resolve(loaderData).then(clearCacheAfterMaxAge, (error) => {
  clearCache();
  throw error;
});

the throw error only affects the chained promise, not the original loaderData that callers await. You can simply:

Promise.resolve(loaderData).then(
  clearCacheAfterMaxAge,
  () => clearCache(),
);

Callers will still see the original rejection, and you avoid creating an extra unobserved rejected promise.


356-435: Stack initialization and store reuse are reasonable; clarify “initial” props semantics

The Stack component’s useMemo calls with empty dependency arrays intentionally treat:

  • initialContext, and
  • initialLoaderData

as one-shot “initialization” values rather than reactive props, and the core store is created once (reusing a previous store in the browser via makeRef). This is a good fit for the typical “configure once at app bootstrap” pattern.

If you ever expect initialContext / initialLoaderData to change over the lifetime of the app (e.g., hot reconfiguration), they’ll currently be ignored after the first render; in that case you’d need to include them in the memo deps and decide how to reinitialize the store. For now, consider documenting these props as immutable boot-time inputs to avoid confusion.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b0bbf6c and a976b8a.

📒 Files selected for processing (3)
  • integrations/react/src/future/defineActivity.ts (1 hunks)
  • integrations/react/src/future/index.ts (1 hunks)
  • integrations/react/src/future/structuredStackflow.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Write source in TypeScript with strict typing enabled across the codebase

Files:

  • integrations/react/src/future/index.ts
  • integrations/react/src/future/structuredStackflow.tsx
  • integrations/react/src/future/defineActivity.ts
integrations/react/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Use .tsx files for React components and JSX in the React integration package

Files:

  • integrations/react/src/future/structuredStackflow.tsx
🔇 Additional comments (4)
integrations/react/src/future/defineActivity.ts (1)

3-47: Activity/loader definitions and defineActivity builder look consistent

ActivityRoute, ActivityLoader, and the ActivityDefinitionInput/Output shapes line up well with how structuredStackflow later consumes routes/loaders and components, and defineActivity correctly preserves the TParams generic and binds it to InternalActivityComponentType<TParams>. I don’t see correctness issues here.

integrations/react/src/future/structuredStackflow.tsx (2)

94-141: Activity flattening and route extraction logic look correct and robust

flattenActivities correctly recurses through nested NavigationDefinitionOutput values, reusing a shared visitedNames set to enforce global uniqueness by def.name and throwing a clear error on duplicates. createRoutesFromActivities mirrors this traversal to build a flat { [activityName]: route } map, which should work well for plugins like history-sync. No issues spotted in the traversal or error handling.


281-334: Typed actions and pop/canGoBack behavior look correct

createActions respects the typed activity name/params, generates fresh activityIds by default, and forwards animate: false into skipEnterActiveState. The pop overload is handled cleanly by normalizing arguments, and the loop that sets skipExitActiveState only for the first popped activity when animating matches typical UX. canGoBack’s check against activities in "enter-done"/"enter-active" also seems aligned with stack semantics.

integrations/react/src/future/index.ts (1)

19-20: Re-exporting defineActivity and structuredStackflow from the future bundle makes sense

Surfacing these two modules from the future index aligns with the new structured navigation API and keeps the public entrypoint coherent. No issues here.

- AllActivityNames extracts def.name instead of map keys
- GetActivityDefinition searches by name, not key
- Added runtime validation for key/name mismatches

Fixes type/runtime mismatch in activity resolution.
Copy link

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
integrations/react/src/future/structuredStackflow.tsx (1)

30-37: Type/runtime activity name alignment + uniqueness checks look correct now

The combination of:

  • AllActivityNames deriving names from ActivityDefinitionOutput<infer N, any> instead of map keys, and
  • flattenActivities enforcing key === def.name and rejecting duplicate def.name values across the entire hierarchy

removes the earlier risk of type-level names diverging from runtime registration and from what loadData / activitiesConfig actually use. This ensures initialActivity / push / replace names, route generation, and loader lookup all consistently operate on defineActivity’s name parameter and that duplicates are caught eagerly.

Also applies to: 91-128

🧹 Nitpick comments (3)
integrations/react/src/future/structuredStackflow.tsx (3)

131-145: Reuse flattenActivities (or its validations) in createRoutesFromActivities

createRoutesFromActivities walks the tree independently and only uses def.name/def.route, so it doesn’t benefit from the key/name equality and duplicate-name checks in flattenActivities. If someone calls createRoutesFromActivities on a destinations tree before (or even without) creating a stack, duplicate activity names will silently result in last-write-wins route entries.

Consider either:

  • building routes from flattenActivities(activities) (which already enforces invariants), e.g.:
-export function createRoutesFromActivities(
-  activities: DestinationsMap,
-  routes: Record<string, any> = {},
-): Record<string, any> {
-  for (const [, def] of Object.entries(activities)) {
-    if (isNavigationDefinition(def)) {
-      createRoutesFromActivities(def.activities, routes);
-    } else {
-      if (def.route) {
-        routes[def.name] = def.route;
-      }
-    }
-  }
-  return routes;
-}
+export function createRoutesFromActivities(
+  activities: DestinationsMap,
+  routes: Record<string, any> = {},
+): Record<string, any> {
+  for (const def of flattenActivities(activities)) {
+    if (def.route) {
+      routes[def.name] = def.route;
+    }
+  }
+  return routes;
+}

or

  • mirroring the duplicate-name detection logic locally.

This keeps the “unique names across the app” guarantee consistent for both the stack and route helpers.


162-173: Minor: simplify activitiesConfig/componentsMap construction

flattenedActivities already materializes { name, route, loader, component, ... }. When deriving activitiesConfig, you re‑spread and then re‑assign the same properties:

const activitiesConfig = flattenedActivities.map(
  ({ component, ...activity }) => ({
    ...activity,
    name: activity.name,
    route: activity.route,
    loader: activity.loader,
  }),
);

This can be simplified to just drop the component and keep the rest as‑is:

-  const activitiesConfig = flattenedActivities.map(
-    ({ component, ...activity }) => ({
-      ...activity,
-      name: activity.name,
-      route: activity.route,
-      loader: activity.loader,
-    }),
-  );
+  const activitiesConfig = flattenedActivities.map(
+    ({ component, ...activity }) => activity,
+  );

componentsMap is already cleanly derived from flattenedActivities, so this keeps both paths straightforward and avoids redundant property copies.


360-369: initialContext memo ignores prop changes – verify this is intentional

initialContext is computed from props.initialContext / props.initialLoaderData but memoized with an empty dependency array:

const initialContext = useMemo(
  () => ({
    ...props.initialContext,
    ...(props.initialLoaderData
      ? { initialLoaderData: props.initialLoaderData }
      : null),
  }),
  [],
);

This means subsequent prop updates to initialContext or initialLoaderData are intentionally ignored after the first render. If the semantics are truly “initial-only” (similar to an initialRoute), this is fine; otherwise, consider adding the props as dependencies or documenting that they’re only read once.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a976b8a and 69c4de6.

📒 Files selected for processing (1)
  • integrations/react/src/future/structuredStackflow.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Write source in TypeScript with strict typing enabled across the codebase

Files:

  • integrations/react/src/future/structuredStackflow.tsx
integrations/react/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Use .tsx files for React components and JSX in the React integration package

Files:

  • integrations/react/src/future/structuredStackflow.tsx

Comment on lines +175 to +243
const loaderDataCacheMap = new Map<
string,
{ params: Record<string, unknown>; data: unknown }[]
>();

const loadData = (
activityName: string,
activityParams: Record<string, unknown>,
) => {
const cache = loaderDataCacheMap.get(activityName);
const cacheEntry = cache?.find((entry) =>
isEqual(entry.params, activityParams),
);

if (cacheEntry) {
return cacheEntry.data;
}

const activityConfig = activitiesConfig.find(
(activity) => activity.name === activityName,
);

if (!activityConfig) {
throw new Error(`Activity ${activityName} is not registered.`);
}

const loaderData = activityConfig.loader?.({
params: activityParams,
config: {
activities: activitiesConfig,
transitionDuration,
},
});

const newCacheEntry = {
params: activityParams,
data: loaderData,
};

if (cache) {
cache.push(newCacheEntry);
} else {
loaderDataCacheMap.set(activityName, [newCacheEntry]);
}

const clearCache = () => {
const cache = loaderDataCacheMap.get(activityName);
if (!cache) return;
loaderDataCacheMap.set(
activityName,
cache.filter((entry) => entry !== newCacheEntry),
);
};

const clearCacheAfterMaxAge = () => {
setTimeout(
clearCache,
(activityConfig.loader as any)?.loaderCacheMaxAge ??
DEFAULT_LOADER_CACHE_MAX_AGE,
);
};

Promise.resolve(loaderData).then(clearCacheAfterMaxAge, (error) => {
clearCache();
throw error;
});

return loaderData;
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Avoid throw inside the Promise rejection handler in loadData (prevents unhandled rejections)

In loadData, this fragment:

Promise.resolve(loaderData).then(clearCacheAfterMaxAge, (error) => {
  clearCache();
  throw error;
});

attaches a rejection handler to loaderData and then rethrows inside that handler. Since the promise returned from .then(...) is not observed anywhere, a rejected loader promise will typically:

  • still reject as expected for its direct consumers, and
  • also create a second, unhandled rejected promise from .then(...) (the rethrow), which can show up as unhandled‑rejection noise in browsers/Node.

You can clear the cache on error without rethrowing here, letting the original loaderData promise carry the rejection to callers:

-    Promise.resolve(loaderData).then(clearCacheAfterMaxAge, (error) => {
-      clearCache();
-      throw error;
-    });
+    Promise.resolve(loaderData).then(
+      clearCacheAfterMaxAge,
+      () => {
+        clearCache();
+      },
+    );

This preserves behavior for callers of loadData while avoiding extra unhandled rejections.


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