From 90a28fdb20d0e6c0ae519b82b7d7c8cf90d824ae Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Tue, 4 Aug 2026 12:56:11 +0900 Subject: [PATCH 1/7] docs(plugin-activity-guard): add README --- extensions/plugin-activity-guard/README.md | 165 +++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 extensions/plugin-activity-guard/README.md diff --git a/extensions/plugin-activity-guard/README.md b/extensions/plugin-activity-guard/README.md new file mode 100644 index 000000000..9b70f7785 --- /dev/null +++ b/extensions/plugin-activity-guard/README.md @@ -0,0 +1,165 @@ +# @stackflow/plugin-activity-guard + +`@stackflow/plugin-activity-guard` applies synchronous entry rules before an +Activity is pushed, replaced, or selected as the initial Activity. A Guard can +allow the requested Activity or redirect the entry to another registered +Activity before the Stack changes. + +Use it for client-side navigation policies such as sign-in, onboarding, or +terms checks. It controls which Activity enters the Stack; it is not an +authorization boundary for protected data or server resources. + +## Installation + +```bash +yarn add @stackflow/plugin-activity-guard +``` + +The package requires `@stackflow/config` 2.x and `@stackflow/core` 3.x as peer +dependencies. + +## Setup + +Register the plugin with a Guard for each Activity that has an entry policy. +The Activity names and parameters are inferred from your +`@stackflow/config` registration. + +```ts +// stackflow.config.ts +import { defineConfig } from "@stackflow/config"; + +declare module "@stackflow/config" { + interface Register { + Home: {}; + Checkout: { orderId: string }; + SignIn: { returnTo: string }; + Terms: { orderId: string }; + } +} + +export const config = defineConfig({ + activities: [ + { name: "Home" }, + { name: "Checkout" }, + { name: "SignIn" }, + { name: "Terms" }, + ], + initialActivity: () => "Home", + transitionDuration: 350, +}); +``` + +```tsx +import type { ActivityGuardFor } from "@stackflow/plugin-activity-guard"; +import { + activityGuardPlugin, + all, + redirect, +} from "@stackflow/plugin-activity-guard"; +import { stackflow } from "@stackflow/react"; +import { config } from "./stackflow.config"; +import { Checkout, Home, SignIn, Terms } from "./activities"; + +const requireSignIn: ActivityGuardFor<"Checkout"> = ({ activityParams }) => + isSignedIn() + ? true + : redirect("SignIn", { returnTo: activityParams.orderId }); + +const requireTerms: ActivityGuardFor<"Checkout"> = ({ activityParams }) => + hasAcceptedTerms() + ? true + : redirect("Terms", { orderId: activityParams.orderId }); + +export const { Stack } = stackflow({ + config, + components: { + Home, + Checkout, + SignIn, + Terms, + }, + plugins: [ + activityGuardPlugin({ + guards: { + Checkout: all(requireSignIn, requireTerms), + }, + }), + ], +}); +``` + +Each Guard receives the requested `activityName` and its typed +`activityParams`. Return `true` to allow the entry, or return +`redirect(activityName, activityParams)` to replace its target. In the example, +`all()` evaluates both Guards in order and stops at the first redirect. + +## Behavior and limitations + +- Activities without a registered Guard are allowed. +- Redirect destinations are guarded again. Redirect chains must eventually + reach an allowed or unguarded Activity; redirect cycles are not detected. +- Guards run synchronously. If a Guard throws, the error is propagated and the + requested `push` or `replace` is not dispatched. An error during initial + entry aborts Stack creation. +- A redirect preserves whether the original operation was a `push` or + `replace`, along with its other action parameters. +- Guards run for fresh initial navigation, but not when Stackflow restores a + snapshot. They also do not run for `pop`, Activity reactivation, or step + navigation. +- When a fresh initial entry is redirected, later events in that initial event + sequence are discarded. + +Stackflow invokes plugins in array order. During initialization, this plugin +guards the initial events returned by earlier plugins. Place it after a plugin +that chooses the initial Activity, such as `historySyncPlugin()`, when that +plugin's destination should be guarded. For `push` and `replace`, a Guard sees +action parameters overridden by earlier plugins, and later plugins can override +the redirected target again. + +## Public API + +### `activityGuardPlugin(options)` + +Creates the Stackflow plugin. `options.guards` is a partial map from registered +Activity names to their Guards. + +```ts +interface ActivityGuardPluginOptions { + guards: Guards; +} +``` + +### `ActivityGuardFor` + +A synchronous Guard for one registered Activity. + +```ts +type ActivityGuardFor = (input: { + activityName: ActivityName; + activityParams: InferActivityParams; +}) => GuardResolution; +``` + +`GuardResolution` is either `true` or a redirect target. Use the exported +`redirect()` helper to create a redirect resolution so that the destination +name and parameters remain type-checked. + +### `all(...guards)` + +Combines one or more Guards for the same Activity. It evaluates them in the +given order, returns the first redirect, and returns `true` only when every +Guard returns `true`. + +### `redirect(activityName, activityParams)` + +Creates a typed redirect resolution. Calling `redirect()` does not navigate by +itself; the redirect is applied only when a Guard returns the resolution. + +### `resolveGuards(origin, guards)` + +Resolves a target through the provided Guard map until a Guard allows it or no +Guard is registered. It returns the final `target` and `blocked`, which is +`true` when at least one redirect was followed. + +The package also exports the supporting types `ActivityGuard`, `Guards`, +`GuardResolution`, `Target`, and `NonEmptyArray`. From 64f3bf8743f5d52e4463834944c63209cdd51ff6 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Tue, 4 Aug 2026 14:31:40 +0900 Subject: [PATCH 2/7] docs(plugin-activity-guard): clarify introduction --- extensions/plugin-activity-guard/README.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/extensions/plugin-activity-guard/README.md b/extensions/plugin-activity-guard/README.md index 9b70f7785..791c31860 100644 --- a/extensions/plugin-activity-guard/README.md +++ b/extensions/plugin-activity-guard/README.md @@ -1,13 +1,17 @@ # @stackflow/plugin-activity-guard -`@stackflow/plugin-activity-guard` applies synchronous entry rules before an -Activity is pushed, replaced, or selected as the initial Activity. A Guard can -allow the requested Activity or redirect the entry to another registered -Activity before the Stack changes. - -Use it for client-side navigation policies such as sign-in, onboarding, or -terms checks. It controls which Activity enters the Stack; it is not an -authorization boundary for protected data or server resources. +Applications often need to redirect users away from an Activity until entry +conditions such as sign-in, onboarding, or terms acceptance are satisfied. +Implementing those checks at individual navigation call sites or inside +Activity components duplicates the policy and can apply it inconsistently. + +`@stackflow/plugin-activity-guard` centralizes these entry policies as typed, +synchronous Guards. Before an Activity is pushed, replaced, or selected as the +initial Activity, a Guard can allow the requested Activity or redirect the +entry to another registered Activity before the Stack changes. + +The package controls client-side navigation. It is not an authorization +boundary for protected data or server resources. ## Installation From 175a8996d5c872c9508ccd22cd65ab4f1f92f13c Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Tue, 4 Aug 2026 14:50:55 +0900 Subject: [PATCH 3/7] docs(plugin-activity-guard): generalize entry problem --- extensions/plugin-activity-guard/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/plugin-activity-guard/README.md b/extensions/plugin-activity-guard/README.md index 791c31860..6e9cf26d2 100644 --- a/extensions/plugin-activity-guard/README.md +++ b/extensions/plugin-activity-guard/README.md @@ -1,9 +1,9 @@ # @stackflow/plugin-activity-guard -Applications often need to redirect users away from an Activity until entry -conditions such as sign-in, onboarding, or terms acceptance are satisfied. -Implementing those checks at individual navigation call sites or inside -Activity components duplicates the policy and can apply it inconsistently. +Applications often need to control users' entry into an Activity based on +conditions such as sign-in, onboarding, or terms acceptance. Implementing +those entry policies at individual navigation call sites or inside Activity +components duplicates the policy and can apply it inconsistently. `@stackflow/plugin-activity-guard` centralizes these entry policies as typed, synchronous Guards. Before an Activity is pushed, replaced, or selected as the From 1eb3be9f425bc0a8aebc873732d9c859838974b4 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Tue, 4 Aug 2026 15:21:02 +0900 Subject: [PATCH 4/7] docs(plugin-activity-guard): separate setup and usage --- extensions/plugin-activity-guard/README.md | 77 +++++++++------------- 1 file changed, 30 insertions(+), 47 deletions(-) diff --git a/extensions/plugin-activity-guard/README.md b/extensions/plugin-activity-guard/README.md index 6e9cf26d2..ccf003851 100644 --- a/extensions/plugin-activity-guard/README.md +++ b/extensions/plugin-activity-guard/README.md @@ -24,60 +24,20 @@ dependencies. ## Setup -Register the plugin with a Guard for each Activity that has an entry policy. -The Activity names and parameters are inferred from your -`@stackflow/config` registration. - -```ts -// stackflow.config.ts -import { defineConfig } from "@stackflow/config"; - -declare module "@stackflow/config" { - interface Register { - Home: {}; - Checkout: { orderId: string }; - SignIn: { returnTo: string }; - Terms: { orderId: string }; - } -} - -export const config = defineConfig({ - activities: [ - { name: "Home" }, - { name: "Checkout" }, - { name: "SignIn" }, - { name: "Terms" }, - ], - initialActivity: () => "Home", - transitionDuration: 350, -}); -``` +Add `activityGuardPlugin()` to your Stackflow configuration and map each +guarded Activity to its Guard. This example assumes `Checkout`, `SignIn`, and +`Terms` are already registered in `@stackflow/config`. ```tsx -import type { ActivityGuardFor } from "@stackflow/plugin-activity-guard"; -import { - activityGuardPlugin, - all, - redirect, -} from "@stackflow/plugin-activity-guard"; +import { activityGuardPlugin } from "@stackflow/plugin-activity-guard"; import { stackflow } from "@stackflow/react"; import { config } from "./stackflow.config"; -import { Checkout, Home, SignIn, Terms } from "./activities"; - -const requireSignIn: ActivityGuardFor<"Checkout"> = ({ activityParams }) => - isSignedIn() - ? true - : redirect("SignIn", { returnTo: activityParams.orderId }); - -const requireTerms: ActivityGuardFor<"Checkout"> = ({ activityParams }) => - hasAcceptedTerms() - ? true - : redirect("Terms", { orderId: activityParams.orderId }); +import { Checkout, SignIn, Terms } from "./activities"; +import { checkoutGuard } from "./checkoutGuard"; export const { Stack } = stackflow({ config, components: { - Home, Checkout, SignIn, Terms, @@ -85,13 +45,36 @@ export const { Stack } = stackflow({ plugins: [ activityGuardPlugin({ guards: { - Checkout: all(requireSignIn, requireTerms), + Checkout: checkoutGuard, }, }), ], }); ``` +## Usage + +The following Guard requires both sign-in and terms acceptance before entering +`Checkout`. Activity names and parameters are inferred from your +`@stackflow/config` registration. + +```ts +import type { ActivityGuardFor } from "@stackflow/plugin-activity-guard"; +import { all, redirect } from "@stackflow/plugin-activity-guard"; + +const requireSignIn: ActivityGuardFor<"Checkout"> = ({ activityParams }) => + isSignedIn() + ? true + : redirect("SignIn", { returnTo: activityParams.orderId }); + +const requireTerms: ActivityGuardFor<"Checkout"> = ({ activityParams }) => + hasAcceptedTerms() + ? true + : redirect("Terms", { orderId: activityParams.orderId }); + +export const checkoutGuard = all(requireSignIn, requireTerms); +``` + Each Guard receives the requested `activityName` and its typed `activityParams`. Return `true` to allow the entry, or return `redirect(activityName, activityParams)` to replace its target. In the example, From db9d30cb1f1cd629486099b56b710c5ab373c3c7 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Tue, 4 Aug 2026 15:21:22 +0900 Subject: [PATCH 5/7] docs(plugin-activity-guard): remove peer dependency duplication --- extensions/plugin-activity-guard/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/extensions/plugin-activity-guard/README.md b/extensions/plugin-activity-guard/README.md index ccf003851..1291ba71c 100644 --- a/extensions/plugin-activity-guard/README.md +++ b/extensions/plugin-activity-guard/README.md @@ -19,9 +19,6 @@ boundary for protected data or server resources. yarn add @stackflow/plugin-activity-guard ``` -The package requires `@stackflow/config` 2.x and `@stackflow/core` 3.x as peer -dependencies. - ## Setup Add `activityGuardPlugin()` to your Stackflow configuration and map each From 6d46e62f7342a5b4ab8f605cd174a0262dd5a07e Mon Sep 17 00:00:00 2001 From: Jaewon Seo Date: Tue, 4 Aug 2026 15:35:11 +0900 Subject: [PATCH 6/7] Update README.md --- extensions/plugin-activity-guard/README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/extensions/plugin-activity-guard/README.md b/extensions/plugin-activity-guard/README.md index 1291ba71c..03a57d2c2 100644 --- a/extensions/plugin-activity-guard/README.md +++ b/extensions/plugin-activity-guard/README.md @@ -59,10 +59,10 @@ The following Guard requires both sign-in and terms acceptance before entering import type { ActivityGuardFor } from "@stackflow/plugin-activity-guard"; import { all, redirect } from "@stackflow/plugin-activity-guard"; -const requireSignIn: ActivityGuardFor<"Checkout"> = ({ activityParams }) => +const requireSignIn: ActivityGuardFor<"Checkout"> = ({ activityName, activityParams }) => isSignedIn() ? true - : redirect("SignIn", { returnTo: activityParams.orderId }); + : redirect("SignIn", { returnTo: { activityName, activityParams } }); const requireTerms: ActivityGuardFor<"Checkout"> = ({ activityParams }) => hasAcceptedTerms() @@ -79,12 +79,9 @@ Each Guard receives the requested `activityName` and its typed ## Behavior and limitations -- Activities without a registered Guard are allowed. - Redirect destinations are guarded again. Redirect chains must eventually reach an allowed or unguarded Activity; redirect cycles are not detected. -- Guards run synchronously. If a Guard throws, the error is propagated and the - requested `push` or `replace` is not dispatched. An error during initial - entry aborts Stack creation. +- Guards must not throw any errors. - A redirect preserves whether the original operation was a `push` or `replace`, along with its other action parameters. - Guards run for fresh initial navigation, but not when Stackflow restores a From a212473cb7497d998a8a925de669e55b2a96bd97 Mon Sep 17 00:00:00 2001 From: Jaewon Seo Date: Tue, 4 Aug 2026 15:36:17 +0900 Subject: [PATCH 7/7] Update README.md --- extensions/plugin-activity-guard/README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/extensions/plugin-activity-guard/README.md b/extensions/plugin-activity-guard/README.md index 03a57d2c2..bb5b9bdf4 100644 --- a/extensions/plugin-activity-guard/README.md +++ b/extensions/plugin-activity-guard/README.md @@ -135,12 +135,3 @@ Guard returns `true`. Creates a typed redirect resolution. Calling `redirect()` does not navigate by itself; the redirect is applied only when a Guard returns the resolution. - -### `resolveGuards(origin, guards)` - -Resolves a target through the provided Guard map until a Guard allows it or no -Guard is registered. It returns the final `target` and `blocked`, which is -`true` when at least one redirect was followed. - -The package also exports the supporting types `ActivityGuard`, `Guards`, -`GuardResolution`, `Target`, and `NonEmptyArray`.