feat(core,plugin-history-sync): allow step actions for non-top activities
Summary
This PR enables step actions (stepPush, stepPop, stepReplace) to target any activity in the stack using targetActivityId, not just the top activity. The history sync plugin now intelligently synchronizes browser history with core state when navigating to modified activities.
Motivation
Previously, step actions could only modify the currently active (top) activity. This limitation prevented useful patterns like modifying the previous activity's parameters when popping the current activity.
Use Case: When popping an activity, update the previous activity's state in a single operation to avoid duplicate plugin logs:
// Before: Two separate operations → two plugin logs
actions.pop();
actions.stepReplace({ newParams }, { targetActivityId: previousActivityId });
// After: Atomic operation → single plugin log
actions.pop({
onBeforePop: () => {
actions.stepReplace({ newParams }, { targetActivityId: previousActivityId });
}
});
Changes
Core (@stackflow/core)
Modified: core/src/activity-utils/findTargetActivityIndices.ts
- Step actions now search for the target activity by ID when targetActivityId is specified
- Falls back to latest active activity when targetActivityId is not provided (backward compatible)
Updated: core/src/aggregate.spec.ts
- Modified existing test to reflect new behavior
- Added comprehensive tests for all step actions targeting lower activities
History Sync Plugin (@stackflow/plugin-history-sync)
Modified: extensions/plugin-history-sync/src/historySyncPlugin.tsx
- Added synchronization logic in onPopState handler
- Core state is treated as the single source of truth
- History state automatically syncs when navigating to modified activities
Technical Details
Synchronization Strategy
When navigating back to an activity that was modified while not active, the plugin compares history state with core state:
if (historyStep not in coreSteps) {
if (coreSteps.length < historySteps.length) {
// Step Pop: Step was removed
history.back(); // Skip removed step entry
} else {
// Step Replace/Push: Step was replaced or added
replaceState({ step: coreLastStep }); // Show current state
}
} else {
// Step exists: proceed with normal navigation
}
Why This Approach?
- Step Pop →
history.back()
- Avoids duplicate history entries
- Cleanly skips removed steps
- Example: [s1][s2][s3] → s3 removed → navigates to s2 directly
- Step Replace/Push → replaceState()
- Shows current core state immediately
- Accepts forward history volatility (aligns with stack semantics)
- Example: [s1] → replaced with s2 → shows s2 when navigating back
- Stack Semantics
- Modifying a lower stack element invalidates upper elements
- Forward history volatility is expected and correct behavior
- Aligns with stack data structure principles
Behavior
Scenario 1: Step Replace
Stack: [A(initial)] [B]
Action: stepReplace({ tab: 'profile' }, { targetActivityId: 'A' })
Core: [A(profile)] [B]
Navigate back to A:
→ Shows 'profile' tab ✅
Scenario 2: Multiple Step Pops
Stack: [A(s1, s2, s3)] [B]
Action: stepPop twice on A
Core: [A(s1)] [B]
Navigate back:
→ Skips s3 and s2, shows s1 ✅
Scenario 3: Complex Operations (Pop + Push)
Stack: [A(s1, s2, s3)] [B]
Actions: stepPop s3, stepPush s4 on A
Core: [A(s1, s2, s4)] [B]
Navigate back to s2:
→ Shows s2 normally (exists in core) ✅
Navigate back to s3:
→ Replaced with s4 (s3 doesn't exist) ✅
Limitations
- Forward History Volatility
When step actions modify lower activities, forward navigation may be affected:
History: [A(initial)] [B] [C]
^^^
Action: stepReplace on A while at C
Result: [A(replaced)] [B] ← C is lost
This is correct behavior per stack semantics:
modifying a lower element invalidates upper elements.
- Page Refresh
After refresh, only the latest step state is preserved. Intermediate step history is lost:
Before refresh: [A(s1)] [A(s2)] [A(s3)]
After refresh: [A(s3)]
This is acceptable for most applications where only the current state matters.
- Step Push Considerations
While stepPush to lower activities is supported, stepReplace is generally recommended as it provides more predictable behavior without creating missing history entries.
Testing
- [x] Core tests: Step actions target lower activities correctly
- [x] Step Pop: Multiple consecutive pops
- [x] Step Replace: Parameter updates
- [x] Step Push: New steps added
- [x] Complex operations: Pop + Push combinations
- [x] Edge cases: Various step operation sequences
Breaking Changes
None. This change is backward compatible:
- When targetActivityId is not specified, behavior is unchanged (targets top activity)
- Existing code continues to work without modifications
Related
Implements the feature discussed in the use case of modifying previous activity parameters during pop operations while maintaining a single plugin log entry.
🦋 Changeset detected
Latest commit: 8807c1273f0ffd9c0331a73db525629e5a929674
The changes in this PR will be included in the next version bump.
This PR includes changesets to release 2 packages
| Name | Type |
|---|---|
| @stackflow/core | Minor |
| @stackflow/plugin-history-sync | 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
📝 Walkthrough
Summary by CodeRabbit
-
New Features
- Step actions (push, pop, replace) can now target and modify lower (non-active) activities in the stack.
- Enhanced history synchronization intelligently handles navigation when lower activities are modified.
-
Tests
- Comprehensive test coverage added for targeting lower activities and cross-activity navigation scenarios.
Walkthrough
Step actions (push/replace/pop) now resolve a target activity by preferring an explicit targetActivityId or falling back to the latest active activity; targets are appended only when the resolved activity exists (for pop, only if it has >1 step). Tests, event export, and history-sync updated to support lower-activity operations and syncing.
Changes
| Cohort / File(s) | Change summary |
|---|---|
Core: activity target resolution core/src/activity-utils/findTargetActivityIndices.ts |
Replace prior "use latest active with early mismatch break" flow with explicit target resolution: prefer event.targetActivityId or fall back to latest active; append resolved target indices only when target exists (for pop, append only if target has >1 step). No public signatures in core changed. |
Core: tests & event types core/src/aggregate.spec.ts, core/src/event-types.ts |
Exported StepPoppedEvent added to event-types; tests updated/added to cover StepPushed/StepReplaced/StepPopped targeting lower activities and to assert propagated step/activity params and z-index behavior. |
History sync plugin & tests extensions/plugin-history-sync/src/historySyncPlugin.tsx, extensions/plugin-history-sync/src/historySyncPlugin.spec.ts |
Add handling for non-active (lower) activities during history sync: resolve target activity/step, detect missing core step and either back-navigate or replaceState to align history; add guards to skip non-active cases where appropriate. Tests added for lower-activity stepPop/stepPush history interactions. |
History sync plugin API extensions/plugin-history-sync/src/historySyncPlugin.tsx |
Public configuration change: onBeforeStepPop handler now receives an extra actionParams parameter (signature changed from onBeforeStepPop({ actions: { getStack } }) to onBeforeStepPop({ actionParams, actions: { getStack } })). |
Changelog / Changeset .changeset/lower-activity-step-actions.md |
Document targetActivityId support for step actions, history-sync behavior for non-top activity changes (history.back / replaceState examples), and backward compatibility note. |
Sequence Diagram(s)
sequenceDiagram
participant Browser as Browser History (popstate)
participant Plugin as HistorySyncPlugin
participant Core as Core State
participant Tick as requestHistoryTick
Browser->>Plugin: popstate (activityId?, stepIndex)
Plugin->>Core: resolve targetActivity (use event.targetActivityId || latestActive)
alt targetActivity not found
Plugin->>Plugin: continue existing navigation flow (no lower-activity handling)
else targetActivity found
Plugin->>Core: compare history entry vs core steps for targetActivity
alt history step missing in core (step was popped)
Plugin->>Tick: schedule back navigation
Tick->>Browser: history.back()
else history step exists but mismatches
Plugin->>Tick: schedule replaceState
Tick->>Browser: history.replaceState(...)
else
Plugin->>Plugin: normal navigation handling (sync URL/state)
end
end
Estimated code review effort
🎯 4 (Complex) | ⏱️ ~45 minutes
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 PR title "feat(core,plugin-history-sync): allow step actions for non-top activities" is concise, specific, and directly summarizes the main change in the changeset. It clearly indicates that the PR extends step actions (stepPush, stepPop, stepReplace) to work on non-top activities via the new targetActivityId parameter. The title is neither vague nor off-topic, and it follows conventional commit format while avoiding unnecessary noise. This accurately represents the primary objective of the changes across core and the history sync plugin. |
| Description Check | ✅ Passed | The PR description is comprehensive and directly related to the changeset, providing clear explanations of the feature, motivation, implementation details, and limitations. It describes the core changes to findTargetActivityIndices.ts, updates to aggregate.spec.ts tests, and the synchronization logic added to the history sync plugin. The description includes concrete use cases, technical details about the synchronization strategy, and explicit statements about backward compatibility. This level of detail is substantive and directly tied to the actual changes, far exceeding the minimal requirements for this lenient check. |
✨ Finishing touches
- [ ] 📝 Generate docstrings
🧪 Generate unit tests (beta)
- [ ] Create PR with unit tests
- [ ] Post copyable unit tests in a comment
- [ ] Commit unit tests in branch
claude/modify-step-action-scope-011CUNBq87uzYD4N35PjiTbi
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.
Comment @coderabbitai help to get the list of available commands and usage tips.
Deploying stackflow-demo with
Cloudflare Pages
| Latest commit: |
8807c12
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://2d3eb623.stackflow-demo.pages.dev |
| Branch Preview URL: | https://claude-modify-step-action-sc.stackflow-demo.pages.dev |
yarn add https://pkg.pr.new/@stackflow/[email protected]
yarn add https://pkg.pr.new/@stackflow/[email protected]
commit: 8807c12
Deploying with
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 | 8807c127 | Commit Preview URL | Oct 24 2025, 02:22 AM |