diff --git a/.changeset/coerce-activity-params-to-string.md b/.changeset/coerce-activity-params-to-string.md deleted file mode 100644 index cb463d1ea..000000000 --- a/.changeset/coerce-activity-params-to-string.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@stackflow/plugin-history-sync": minor ---- - -Coerce activity/step params to `string | undefined` at the plugin boundary. - -Before this change, `push("X", { visible: true })` would store the boolean `true` in the core store while URL-arrival parsed the same URL as `{ visible: "true" }`, so `useActivityParams()` returned different runtime types depending on how the user reached the activity. This PR coerces non-string values to strings inside `plugin-history-sync`'s `onBeforePush` / `onBeforeReplace` / `onBeforeStepPush` / `onBeforeStepReplace` hooks (after `encode` consumes the typed params to build the URL), and on the `decode`-path in `overrideInitialEvents`, so the core store always contains `{ [key: string]: string | undefined }`. `encode` still receives the typed params `U` from `template.fill`. Post-effect hooks (`onPushed`, `onReplaced`, `onStepPushed`, `onStepReplaced`, `onInit`) now use the new `fillWithoutEncode` to avoid re-running `encode` on already-coerced store values. - -This is a behavioral change for consumers that relied on internal push preserving non-string values in the store (a pre-existing divergence from URL-arrival behavior). See the docs update for the migration note. - -Migration notes: - -- If you authored a `decode` hook that returns typed values (e.g. `decode: (p) => ({ count: Number(p.count) })`), those return values are now coerced back to strings in the store to match the declared `ActivityBaseParams` contract. Move runtime type coercion to the usage site (`Number(useActivityParams().count)`). -- If your app registers a plugin AFTER `historySyncPlugin` in the plugins array and that plugin re-injects typed values via `overrideActionParams`, those values will NOT be coerced by this plugin. Register `historySyncPlugin` last among plugins that mutate `activityParams` to preserve the string-only invariant. -- Cross-deploy hydration: when a user reloads on a deploy that includes this fix after a previous deploy serialized typed values into `history.state`, the params are coerced to strings at hydration time inside the `parseState` early-return. No consumer change required — the post-fix runtime contract (`useActivityParams()` returns `string | undefined`) holds across version boundaries. diff --git a/.changeset/step-context-path.md b/.changeset/step-context-path.md deleted file mode 100644 index d3da1577d..000000000 --- a/.changeset/step-context-path.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@stackflow/core": minor -"@stackflow/plugin-history-sync": patch ---- - -Add optional `stepContext.path?: string` to `StepPushedEvent` and `StepReplacedEvent` (purely additive, no breaking change). `@stackflow/plugin-history-sync` uses this to preserve `encode`-output URLs through the store across every step navigation path — including `popstate` forward across step boundaries — instead of relying on plugin-internal state. - -This addresses three regressions surfaced in PR review: - -1. **`encode` output not in `history.location`** — post-effect hooks (`onPushed` / `onReplaced` / `onStepPushed` / `onStepReplaced` / `onInit`) called `template.fillWithoutEncode(activity.params)` against the post-coercion strings, skipping `encode` and writing coerced values into the URL. Now they read the encoded URL pre-computed in pre-effect hooks (`activityContext.path` / `stepContext.path`), with `fillWithoutEncode` as a defensive fallback only. -2. **`encode` called with coerced strings on popstate forward re-push** — the popstate `isForward` and `isStepForward` branches reconstructed push events without preserving `activityContext` / `stepContext`, causing `onBeforePush` / `onBeforeStepPush` to call `template.fill` with already-coerced strings. Now those branches pass `activityContext: targetActivity.context` / `stepContext: targetStep.context`, and the pre-effect hooks short-circuit when the path is already present (`"path" in actionParams.activityContext`). -3. **Test gap: `path(history.location)` was never asserted under non-identity `encode`** — every existing test asserted `activity.context.path` only. Added 15 new tests asserting the URL surface under non-identity encode, including popstate-forward across activity AND step boundaries, `defaultHistory` ancestor URLs, SSR replay, and `replace`-with-active-steps. diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 75ae2a21c..cddfb33f6 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,5 +1,17 @@ # @stackflow/core +## 1.4.0 + +### Minor Changes + +- cef9c62: Add optional `stepContext.path?: string` to `StepPushedEvent` and `StepReplacedEvent` (purely additive, no breaking change). `@stackflow/plugin-history-sync` uses this to preserve `encode`-output URLs through the store across every step navigation path — including `popstate` forward across step boundaries — instead of relying on plugin-internal state. + + This addresses three regressions surfaced in PR review: + + 1. **`encode` output not in `history.location`** — post-effect hooks (`onPushed` / `onReplaced` / `onStepPushed` / `onStepReplaced` / `onInit`) called `template.fillWithoutEncode(activity.params)` against the post-coercion strings, skipping `encode` and writing coerced values into the URL. Now they read the encoded URL pre-computed in pre-effect hooks (`activityContext.path` / `stepContext.path`), with `fillWithoutEncode` as a defensive fallback only. + 2. **`encode` called with coerced strings on popstate forward re-push** — the popstate `isForward` and `isStepForward` branches reconstructed push events without preserving `activityContext` / `stepContext`, causing `onBeforePush` / `onBeforeStepPush` to call `template.fill` with already-coerced strings. Now those branches pass `activityContext: targetActivity.context` / `stepContext: targetStep.context`, and the pre-effect hooks short-circuit when the path is already present (`"path" in actionParams.activityContext`). + 3. **Test gap: `path(history.location)` was never asserted under non-identity `encode`** — every existing test asserted `activity.context.path` only. Added 15 new tests asserting the URL surface under non-identity encode, including popstate-forward across activity AND step boundaries, `defaultHistory` ancestor URLs, SSR replay, and `replace`-with-active-steps. + ## 1.3.2 ### Patch Changes diff --git a/core/package.json b/core/package.json index 691dbc17c..b43b15228 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@stackflow/core", - "version": "1.3.2", + "version": "1.4.0", "repository": { "type": "git", "url": "https://github.com/daangn/stackflow.git", diff --git a/docs/components/ChangelogContent.mdx b/docs/components/ChangelogContent.mdx index b70f13528..face23b0d 100644 --- a/docs/components/ChangelogContent.mdx +++ b/docs/components/ChangelogContent.mdx @@ -1,3 +1,36 @@ +## 2026.05.08 + +Coerce activity/step params to `string | undefined` at the plugin boundary. [`cef9c62`](https://github.com/daangn/stackflow/commit/cef9c62700eeda32fa7698b991281e8506df670e) + +Before this change, `push("X", { visible: true })` would store the boolean `true` in the core store while URL-arrival parsed the same URL as `{ visible: "true" }`, so `useActivityParams``()` returned different runtime types depending on how the user reached the activity. This PR coerces non-string values to strings inside `plugin-history-sync`'s `onBeforePush` / `onBeforeReplace` / `onBeforeStepPush` / `onBeforeStepReplace` hooks (after `encode` consumes the typed params to build the URL), and on the `decode`-path in `overrideInitialEvents`, so the core store always contains `{ [key: string]: string | undefined }`. `encode` still receives the typed params `U` from `template.fill`. Post-effect hooks (`onPushed`, `onReplaced`, `onStepPushed`, `onStepReplaced`, `onInit`) now use the new `fillWithoutEncode` to avoid re-running `encode` on already-coerced store values. + +This is a behavioral change for consumers that relied on internal push preserving non-string values in the store (a pre-existing divergence from URL-arrival behavior). See the docs update for the migration note. + +Migration notes: + +- If you authored a `decode` hook that returns typed values (e.g. `decode: (p) => ({ count: Number(p.count) })`), those return values are now coerced back to strings in the store to match the declared `ActivityBaseParams` contract. Move runtime type coercion to the usage site (`Number(useActivityParams().count)`). +- If your app registers a plugin AFTER `historySyncPlugin` in the plugins array and that plugin re-injects typed values via `overrideActionParams`, those values will NOT be coerced by this plugin. Register `historySyncPlugin` last among plugins that mutate `activityParams` to preserve the string-only invariant. +- Cross-deploy hydration: when a user reloads on a deploy that includes this fix after a previous deploy serialized typed values into `history.state`, the params are coerced to strings at hydration time inside the `parseState` early-return. No consumer change required — the post-fix runtime contract (`useActivityParams()` returns `string | undefined`) holds across version boundaries. + +Released packages: +- 📦 [@stackflow/plugin-history-sync@1.11.0](https://npmjs.com/package/@stackflow/plugin-history-sync/v/1.11.0) + +--- + +Add optional `stepContext.path?: string` to `StepPushedEvent` and `StepReplacedEvent` (purely additive, no breaking change). `@stackflow/plugin-history-sync` uses this to preserve `encode`-output URLs through the store across every step navigation path — including `popstate` forward across step boundaries — instead of relying on plugin-internal state. [`cef9c62`](https://github.com/daangn/stackflow/commit/cef9c62700eeda32fa7698b991281e8506df670e) + +This addresses three regressions surfaced in PR review: + +1. **`encode` output not in `history.location`** — post-effect hooks (`onPushed` / `onReplaced` / `onStepPushed` / `onStepReplaced` / `onInit`) called `template.fillWithoutEncode(activity.params)` against the post-coercion strings, skipping `encode` and writing coerced values into the URL. Now they read the encoded URL pre-computed in pre-effect hooks (`activityContext.path` / `stepContext.path`), with `fillWithoutEncode` as a defensive fallback only. +2. **`encode` called with coerced strings on popstate forward re-push** — the popstate `isForward` and `isStepForward` branches reconstructed push events without preserving `activityContext` / `stepContext`, causing `onBeforePush` / `onBeforeStepPush` to call `template.fill` with already-coerced strings. Now those branches pass `activityContext: targetActivity.context` / `stepContext: targetStep.context`, and the pre-effect hooks short-circuit when the path is already present (`"path" in actionParams.activityContext`). +3. **Test gap: `path(history.location)` was never asserted under non-identity `encode`** — every existing test asserted `activity.context.path` only. Added 15 new tests asserting the URL surface under non-identity encode, including popstate-forward across activity AND step boundaries, `defaultHistory` ancestor URLs, SSR replay, and `replace`-with-active-steps. + +Released packages: +- 📦 [@stackflow/core@1.4.0](https://npmjs.com/package/@stackflow/core/v/1.4.0) +- 📦 [@stackflow/plugin-history-sync@1.11.0](https://npmjs.com/package/@stackflow/plugin-history-sync/v/1.11.0) + +--- + ## 2026.04.30 Fix `fallbackActivity` callback being invoked on every initialization regardless of route matching outcome. Restored the pre-1.8.0 contract: the callback is now called only when no route matches `currentPath`. Apps that perform side effects in this callback (e.g. Sentry logging for unknown deep links) no longer fire on successful matches. [`2c5786a`](https://github.com/daangn/stackflow/commit/2c5786a2934c3d2b74c20e8c57465ae03b3d3416) diff --git a/extensions/plugin-history-sync/CHANGELOG.md b/extensions/plugin-history-sync/CHANGELOG.md index 347f32e08..8eff9060c 100644 --- a/extensions/plugin-history-sync/CHANGELOG.md +++ b/extensions/plugin-history-sync/CHANGELOG.md @@ -1,5 +1,31 @@ # @stackflow/plugin-history-sync +## 1.11.0 + +### Minor Changes + +- cef9c62: Coerce activity/step params to `string | undefined` at the plugin boundary. + + Before this change, `push("X", { visible: true })` would store the boolean `true` in the core store while URL-arrival parsed the same URL as `{ visible: "true" }`, so `useActivityParams()` returned different runtime types depending on how the user reached the activity. This PR coerces non-string values to strings inside `plugin-history-sync`'s `onBeforePush` / `onBeforeReplace` / `onBeforeStepPush` / `onBeforeStepReplace` hooks (after `encode` consumes the typed params to build the URL), and on the `decode`-path in `overrideInitialEvents`, so the core store always contains `{ [key: string]: string | undefined }`. `encode` still receives the typed params `U` from `template.fill`. Post-effect hooks (`onPushed`, `onReplaced`, `onStepPushed`, `onStepReplaced`, `onInit`) now use the new `fillWithoutEncode` to avoid re-running `encode` on already-coerced store values. + + This is a behavioral change for consumers that relied on internal push preserving non-string values in the store (a pre-existing divergence from URL-arrival behavior). See the docs update for the migration note. + + Migration notes: + + - If you authored a `decode` hook that returns typed values (e.g. `decode: (p) => ({ count: Number(p.count) })`), those return values are now coerced back to strings in the store to match the declared `ActivityBaseParams` contract. Move runtime type coercion to the usage site (`Number(useActivityParams().count)`). + - If your app registers a plugin AFTER `historySyncPlugin` in the plugins array and that plugin re-injects typed values via `overrideActionParams`, those values will NOT be coerced by this plugin. Register `historySyncPlugin` last among plugins that mutate `activityParams` to preserve the string-only invariant. + - Cross-deploy hydration: when a user reloads on a deploy that includes this fix after a previous deploy serialized typed values into `history.state`, the params are coerced to strings at hydration time inside the `parseState` early-return. No consumer change required — the post-fix runtime contract (`useActivityParams()` returns `string | undefined`) holds across version boundaries. + +### Patch Changes + +- cef9c62: Add optional `stepContext.path?: string` to `StepPushedEvent` and `StepReplacedEvent` (purely additive, no breaking change). `@stackflow/plugin-history-sync` uses this to preserve `encode`-output URLs through the store across every step navigation path — including `popstate` forward across step boundaries — instead of relying on plugin-internal state. + + This addresses three regressions surfaced in PR review: + + 1. **`encode` output not in `history.location`** — post-effect hooks (`onPushed` / `onReplaced` / `onStepPushed` / `onStepReplaced` / `onInit`) called `template.fillWithoutEncode(activity.params)` against the post-coercion strings, skipping `encode` and writing coerced values into the URL. Now they read the encoded URL pre-computed in pre-effect hooks (`activityContext.path` / `stepContext.path`), with `fillWithoutEncode` as a defensive fallback only. + 2. **`encode` called with coerced strings on popstate forward re-push** — the popstate `isForward` and `isStepForward` branches reconstructed push events without preserving `activityContext` / `stepContext`, causing `onBeforePush` / `onBeforeStepPush` to call `template.fill` with already-coerced strings. Now those branches pass `activityContext: targetActivity.context` / `stepContext: targetStep.context`, and the pre-effect hooks short-circuit when the path is already present (`"path" in actionParams.activityContext`). + 3. **Test gap: `path(history.location)` was never asserted under non-identity `encode`** — every existing test asserted `activity.context.path` only. Added 15 new tests asserting the URL surface under non-identity encode, including popstate-forward across activity AND step boundaries, `defaultHistory` ancestor URLs, SSR replay, and `replace`-with-active-steps. + ## 1.10.1 ### Patch Changes diff --git a/extensions/plugin-history-sync/package.json b/extensions/plugin-history-sync/package.json index f63dbf369..174719168 100644 --- a/extensions/plugin-history-sync/package.json +++ b/extensions/plugin-history-sync/package.json @@ -1,6 +1,6 @@ { "name": "@stackflow/plugin-history-sync", - "version": "1.10.1", + "version": "1.11.0", "repository": { "type": "git", "url": "https://github.com/daangn/stackflow.git", @@ -49,7 +49,7 @@ "devDependencies": { "@graphql-tools/schema": "^10.0.5", "@stackflow/config": "^1.2.1", - "@stackflow/core": "^1.3.0", + "@stackflow/core": "^1.4.0", "@stackflow/esbuild-config": "^1.0.3", "@stackflow/react": "^1.7.0", "@swc/core": "^1.6.6", diff --git a/yarn.lock b/yarn.lock index 7ed3caca3..a8f74dc15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5621,7 +5621,7 @@ __metadata: languageName: unknown linkType: soft -"@stackflow/core@npm:^1.1.0, @stackflow/core@npm:^1.1.1, @stackflow/core@npm:^1.2.0, @stackflow/core@npm:^1.3.0, @stackflow/core@npm:^1.3.1, @stackflow/core@workspace:core": +"@stackflow/core@npm:^1.1.0, @stackflow/core@npm:^1.1.1, @stackflow/core@npm:^1.2.0, @stackflow/core@npm:^1.3.0, @stackflow/core@npm:^1.3.1, @stackflow/core@npm:^1.4.0, @stackflow/core@workspace:core": version: 0.0.0-use.local resolution: "@stackflow/core@workspace:core" dependencies: @@ -5875,7 +5875,7 @@ __metadata: dependencies: "@graphql-tools/schema": "npm:^10.0.5" "@stackflow/config": "npm:^1.2.1" - "@stackflow/core": "npm:^1.3.0" + "@stackflow/core": "npm:^1.4.0" "@stackflow/esbuild-config": "npm:^1.0.3" "@stackflow/react": "npm:^1.7.0" "@swc/core": "npm:^1.6.6"