From cf12056fb1d88e8ac1a4d883d51e36d5bc3de6a1 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 18:19:02 +0900 Subject: [PATCH 1/2] fix(plugin-blocker): use a serializable string key for the internal replay marker The blocker replay marker was a `Symbol`, which leaked into the recorded event log and made a captured `StackSnapshot` unserializable by codecs that reject symbol keys (e.g. `devalue` throws "Cannot stringify POJOs with symbolic keys"). Replace it with a namespaced string key so captured snapshots stay serializable. The per-action replay reentrancy behavior (including nested-navigation judgment during replay) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HCjG4TSCrw7bLktTnuzVBp --- .../blocker-replay-marker-serializable.md | 5 ++ .../plugin-blocker/src/blockerPlugin.spec.tsx | 67 ++++++++++++++++++- .../plugin-blocker/src/blockerPlugin.ts | 6 +- 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 .changeset/blocker-replay-marker-serializable.md diff --git a/.changeset/blocker-replay-marker-serializable.md b/.changeset/blocker-replay-marker-serializable.md new file mode 100644 index 000000000..1bf42430b --- /dev/null +++ b/.changeset/blocker-replay-marker-serializable.md @@ -0,0 +1,5 @@ +--- +"@stackflow/plugin-blocker": patch +--- + +Use a namespaced string key instead of a `Symbol` for the internal replay marker. A `Symbol`-keyed property leaked into the recorded event log, so a captured `StackSnapshot` containing a blocked-then-proceeded navigation could not be serialized by codecs that reject symbol keys (e.g. `devalue` throws `Cannot stringify POJOs with symbolic keys`). The marker is now a plain, serializable key. diff --git a/extensions/plugin-blocker/src/blockerPlugin.spec.tsx b/extensions/plugin-blocker/src/blockerPlugin.spec.tsx index 0c2032350..03cae2299 100644 --- a/extensions/plugin-blocker/src/blockerPlugin.spec.tsx +++ b/extensions/plugin-blocker/src/blockerPlugin.spec.tsx @@ -1,5 +1,5 @@ import { defineConfig } from "@stackflow/config"; -import type { Stack } from "@stackflow/core"; +import type { Stack, StackflowActions } from "@stackflow/core"; import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; import type { StackflowReactPlugin } from "@stackflow/react"; import { stackflow, useFlow } from "@stackflow/react"; @@ -2176,4 +2176,69 @@ describe("blockerPlugin", () => { }); }); }); + + describe("9. Snapshot 직렬화", () => { + it("proceed로 replay된 뒤 캡처된 스냅샷에 symbol 키가 없어 외부 직렬화가 깨지지 않는다", async () => { + // given + let getStack!: () => Stack; + let captureSnapshot!: StackflowActions["captureSnapshot"]; + let capturedProceed!: () => void; + + const spyPlugin: StackflowReactPlugin = () => ({ + key: "spy", + onInit({ actions }) { + getStack = actions.getStack; + captureSnapshot = actions.captureSnapshot; + }, + }); + + function TestActivity() { + useBlocker({ + shouldBlock: () => true, + onBlocked: (_, { proceed }) => { + capturedProceed = proceed; + }, + }); + return
Test
; + } + + function OtherActivity() { + return
Other
; + } + + const config = defineConfig({ + activities: [{ name: "TestActivity" }, { name: "OtherActivity" }], + transitionDuration: 0, + initialActivity: () => "TestActivity", + }); + + const { Stack, actions } = stackflow({ + config, + components: { TestActivity, OtherActivity }, + plugins: [blockerPlugin(), basicRendererPlugin(), spyPlugin], + }); + + render(); + + // when: 네비게이션이 차단된 뒤 proceed로 replay가 실행됨 + await act(async () => { + actions.push("OtherActivity", {}); + }); + await act(async () => { + capturedProceed(); + }); + + // then: replay가 실제로 실행되었고 + const activities = getStack().activities; + expect(activities[activities.length - 1].name).toBe("OtherActivity"); + + // then: 캡처된 스냅샷의 어떤 이벤트에도 symbol 키가 없다. + // (replay marker가 symbol이면 devalue 등 직렬화기가 throw한다) + const snapshot = captureSnapshot(); + for (const event of snapshot.events) { + expect(Object.getOwnPropertySymbols(event)).toHaveLength(0); + } + expect(() => JSON.parse(JSON.stringify(snapshot))).not.toThrow(); + }); + }); }); diff --git a/extensions/plugin-blocker/src/blockerPlugin.ts b/extensions/plugin-blocker/src/blockerPlugin.ts index 61e0041be..4a6a39ef7 100644 --- a/extensions/plugin-blocker/src/blockerPlugin.ts +++ b/extensions/plugin-blocker/src/blockerPlugin.ts @@ -57,7 +57,7 @@ function useBlockerStore(): BlockerStore { } function replayAction( - replayMarker: symbol, + replayMarker: string, store: BlockerStore, action: NavigationAction, ) { @@ -92,7 +92,7 @@ function replayAction( } function handleBeforeNavigation( - replayMarker: symbol, + replayMarker: string, store: BlockerStore, action: NavigationAction, preventDefault: () => void, @@ -164,7 +164,7 @@ function handleBeforeNavigation( export function blockerPlugin(options?: { onError?: (error: unknown) => void; }): StackflowReactPlugin { - const replayMarker = Symbol("@@blockerReplay"); + const replayMarker = "@stackflow/plugin-blocker/replay"; const store: BlockerStore = { blockers: new Map(), From 0b3354784a7debaf757f713ac0b8e545fbd5b2c2 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 18:52:51 +0900 Subject: [PATCH 2/2] test(plugin-blocker): remove the snapshot serialization regression test Drop the regression test added in the previous commit, per review request. The string-key fix in blockerPlugin.ts is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HCjG4TSCrw7bLktTnuzVBp --- .../plugin-blocker/src/blockerPlugin.spec.tsx | 67 +------------------ 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/extensions/plugin-blocker/src/blockerPlugin.spec.tsx b/extensions/plugin-blocker/src/blockerPlugin.spec.tsx index 03cae2299..0c2032350 100644 --- a/extensions/plugin-blocker/src/blockerPlugin.spec.tsx +++ b/extensions/plugin-blocker/src/blockerPlugin.spec.tsx @@ -1,5 +1,5 @@ import { defineConfig } from "@stackflow/config"; -import type { Stack, StackflowActions } from "@stackflow/core"; +import type { Stack } from "@stackflow/core"; import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; import type { StackflowReactPlugin } from "@stackflow/react"; import { stackflow, useFlow } from "@stackflow/react"; @@ -2176,69 +2176,4 @@ describe("blockerPlugin", () => { }); }); }); - - describe("9. Snapshot 직렬화", () => { - it("proceed로 replay된 뒤 캡처된 스냅샷에 symbol 키가 없어 외부 직렬화가 깨지지 않는다", async () => { - // given - let getStack!: () => Stack; - let captureSnapshot!: StackflowActions["captureSnapshot"]; - let capturedProceed!: () => void; - - const spyPlugin: StackflowReactPlugin = () => ({ - key: "spy", - onInit({ actions }) { - getStack = actions.getStack; - captureSnapshot = actions.captureSnapshot; - }, - }); - - function TestActivity() { - useBlocker({ - shouldBlock: () => true, - onBlocked: (_, { proceed }) => { - capturedProceed = proceed; - }, - }); - return
Test
; - } - - function OtherActivity() { - return
Other
; - } - - const config = defineConfig({ - activities: [{ name: "TestActivity" }, { name: "OtherActivity" }], - transitionDuration: 0, - initialActivity: () => "TestActivity", - }); - - const { Stack, actions } = stackflow({ - config, - components: { TestActivity, OtherActivity }, - plugins: [blockerPlugin(), basicRendererPlugin(), spyPlugin], - }); - - render(); - - // when: 네비게이션이 차단된 뒤 proceed로 replay가 실행됨 - await act(async () => { - actions.push("OtherActivity", {}); - }); - await act(async () => { - capturedProceed(); - }); - - // then: replay가 실제로 실행되었고 - const activities = getStack().activities; - expect(activities[activities.length - 1].name).toBe("OtherActivity"); - - // then: 캡처된 스냅샷의 어떤 이벤트에도 symbol 키가 없다. - // (replay marker가 symbol이면 devalue 등 직렬화기가 throw한다) - const snapshot = captureSnapshot(); - for (const event of snapshot.events) { - expect(Object.getOwnPropertySymbols(event)).toHaveLength(0); - } - expect(() => JSON.parse(JSON.stringify(snapshot))).not.toThrow(); - }); - }); });