feat(plugin-blocker): add blockerPlugin and useBlocker hook#682
feat(plugin-blocker): add blockerPlugin and useBlocker hook#682ENvironmentSet wants to merge 38 commits intomainfrom
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…brary Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewrite blockerPlugin to match the tech spec: no-arg blockerPlugin(), useBlocker hook with shouldBlock/onBlocked/bypass, and supporting NavigationEvent/BlockedNavigation types. Implementations are stubs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduce a comprehensive technical specification document for the blockerPlugin, detailing the background, problem definition, design direction, public API, and usage examples. This document aims to unify the implementation of screen exit prevention across various mobile webview-based services.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 8412b23 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
📝 WalkthroughSummary by CodeRabbit
WalkthroughIntroduces a new blocker plugin for Stackflow that enables navigation control via a Changes
Sequence Diagram(s)sequenceDiagram
participant Activity as Activity
participant Blocker as useBlocker Hook
participant Plugin as blockerPlugin
participant Store as BlockerStore
participant Action as Navigation Action
Activity->>Blocker: Register blocker with shouldBlock & onBlocked
Blocker->>Store: Register/unregister blocker on mount/unmount
Action->>Plugin: Navigation triggered (push/pop/replace/step)
Plugin->>Store: Query active blockers from active activities
Store-->>Plugin: Return blockers matching navigation action
alt Any blocker shouldBlock returns true
Plugin->>Plugin: Prevent default navigation
Plugin->>Blocker: Invoke onBlocked(blockedNavigation, {proceed})
Blocker-->>Plugin: Store proceed callback reference
loop Until all blockers call proceed
Blocker->>Blocker: Handle blocking logic
end
Blocker->>Plugin: proceed() called
Plugin->>Store: Check if all blockers have proceeded
alt All blockers proceeded
Plugin->>Plugin: Replay original navigation action
Plugin->>Action: Navigation executes
end
else All blockers allow (shouldBlock returns false)
Plugin->>Action: Navigation executes normally
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
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 |
commit: |
Deploying stackflow-demo with
|
| Latest commit: |
8412b23
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://325bdf91.stackflow-demo.pages.dev |
| Branch Preview URL: | https://add-plugin-blocker.stackflow-demo.pages.dev |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
stackflow-docs | 8412b23 | Commit Preview URL | Mar 15 2026, 05:00 PM |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ride(fn) API bypass was tied to replaying a specific BlockedNavigation object. override(fn) wraps arbitrary navigation in a callback, making the blocker skip check scoped to the callback's synchronous execution. This gives developers a unified, general-purpose way to bypass a blocker for any navigation — not just a previously blocked one. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace override pattern with proceed in onBlocked callback - Update section 2, 3, 4, 5 test assertions for new API - Update useBlocker signature: return void, proceed via onBlocked - Fix NavigationEvent → NavigationAction export in index.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…gation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- wrap each onBlocked call in try/catch so one blocker throwing
does not abort other blockers' notifications
- blockerPlugin() now accepts { onError } option for custom error
handling; defaults to console.error
- add test: "6. 오류 격리"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- add dispatching flag and notifyQueue to BlockerStore - when handleBeforeNavigation is called re-entrantly (e.g. a navigation triggered inside onBlocked), queue the notification instead of dispatching immediately - flush the queue after the current dispatch loop completes, ensuring onBlocked is always called in navigation occurrence order - add tests: "7. 알림 순서" (no re-entrancy, occurrence-order guarantee) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
If a plugin registered before blockerPlugin throws in its onBefore* hook, our hook never runs and skipNext stays true, causing the next unrelated navigation to be silently skipped. Wrap the action method call in try/finally so skipNext is always reset. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
skipNext was a mutable flag on the store, so when an earlier plugin triggered a different navigation inside its onBefore* hook during replay, blockerPlugin consumed the flag for the wrong action — silently bypassing the blocker for the nested navigation while re-blocking the intended replay. Instead, stamp a unique Symbol onto the replayed action's params. Only the exact action object that was replayed carries the marker, so unrelated navigations fired by other plugins always go through normal blocker evaluation. The Symbol is instance-scoped so multiple blockerPlugin instances cannot interfere with each other. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
extensions/plugin-blocker/src/blockerPlugin.ts (1)
243-268: Consider memoizing callbacks or documenting the re-registration behavior.The
useEffectat Lines 254-261 re-registers the blocker wheneveroptions.shouldBlockoroptions.onBlockedchanges. This is intentional to pick up the latest callbacks, but if users pass inline functions without memoization, the blocker entry will be overwritten on every render. While functionally correct (sameidkey), this could be documented or the hook could accept stable references.Additionally,
store.blockersin the dependency array is unnecessary since it's a stableMapreference, though harmless.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@extensions/plugin-blocker/src/blockerPlugin.ts` around lines 243 - 268, The useBlocker hook currently re-registers the blocker whenever options.shouldBlock or options.onBlocked changes (and includes the stable Map store.blockers unnecessarily); to fix, remove store.blockers from the dependency arrays and either document that callers should pass stable callbacks to useBlocker or change the registration to keep a stable entry and update the latest callbacks via refs (e.g., keep idRef and blocker entry stable in useBlocker and store the latest options.shouldBlock/options.onBlocked in mutable refs that the store entry reads), referencing the useBlocker function, options.shouldBlock, options.onBlocked, idRef, useEffect, and store.blockers to locate the code to change.extensions/plugin-blocker/esbuild.config.js (1)
29-29: Consider logging the error before exiting.The
.catch(() => process.exit(1))discards the error details, making build failures harder to diagnose.🔧 Proposed fix
-]).catch(() => process.exit(1)); +]).catch((err) => { + console.error(err); + process.exit(1); +});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@extensions/plugin-blocker/esbuild.config.js` at line 29, The current promise catch at the end of the esbuild build chain swallows the error (the `.catch(() => process.exit(1))` call); update that catch to accept the error parameter and log the error (including message/stack) before calling process.exit(1) so build failures are visible—i.e., modify the final `.catch` on the esbuild build promise to log a clear message and the error details (error/stack) and then exit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@extensions/plugin-blocker/package.json`:
- Line 30: The dev script in package.json uses "&&" so "build:dts --watch" never
starts because "build:js --watch" never exits; change the "dev" script to run
both watchers in parallel by using a concurrent runner (e.g., add "concurrently"
or "npm-run-all" as a devDependency) and update the "dev" script to invoke both
"build:js --watch" and "build:dts --watch" concurrently (optionally with flags
to kill others on exit for proper signal handling). Ensure the package.json
scripts reference the existing "build:js" and "build:dts" script names and add
the chosen tool to devDependencies so CI/local runs work consistently.
---
Nitpick comments:
In `@extensions/plugin-blocker/esbuild.config.js`:
- Line 29: The current promise catch at the end of the esbuild build chain
swallows the error (the `.catch(() => process.exit(1))` call); update that catch
to accept the error parameter and log the error (including message/stack) before
calling process.exit(1) so build failures are visible—i.e., modify the final
`.catch` on the esbuild build promise to log a clear message and the error
details (error/stack) and then exit.
In `@extensions/plugin-blocker/src/blockerPlugin.ts`:
- Around line 243-268: The useBlocker hook currently re-registers the blocker
whenever options.shouldBlock or options.onBlocked changes (and includes the
stable Map store.blockers unnecessarily); to fix, remove store.blockers from the
dependency arrays and either document that callers should pass stable callbacks
to useBlocker or change the registration to keep a stable entry and update the
latest callbacks via refs (e.g., keep idRef and blocker entry stable in
useBlocker and store the latest options.shouldBlock/options.onBlocked in mutable
refs that the store entry reads), referencing the useBlocker function,
options.shouldBlock, options.onBlocked, idRef, useEffect, and store.blockers to
locate the code to change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: bdd3efdd-1337-445f-a7ca-506781a78c09
⛔ Files ignored due to path filters (77)
.yarn/cache/@babel-code-frame-npm-7.29.0-6c4947d913-199e15ff89.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@babel-helper-validator-identifier-npm-7.28.5-1953d49d2b-8e5d9b0133.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@esbuild-darwin-arm64-npm-0.27.3-4c8fed986d-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@esbuild-darwin-x64-npm-0.27.3-f27535b6d7-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@esbuild-linux-arm64-npm-0.27.3-a9fedc262b-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@esbuild-linux-ia32-npm-0.27.3-f45d477f3e-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@esbuild-linux-x64-npm-0.27.3-2041dd7b27-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@testing-library-dom-npm-10.4.1-928d6cd2a7-7f93e09ea0.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@testing-library-react-npm-16.3.2-67b0b894c8-0ca88c6f67.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@types-aria-query-npm-5.0.4-51d2b61619-c0084c389d.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@types-jsdom-npm-20.0.1-5bb899e006-15fbb9a0bf.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@types-tough-cookie-npm-4.0.5-8c5e2162e1-01fd82efc8.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/abab-npm-2.0.6-2662fba7f0-ebe95d7278.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/acorn-globals-npm-7.0.1-97c48c0140-2a2998a547.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/acorn-npm-8.16.0-b2096bf83f-690c673bb4.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/acorn-walk-npm-8.3.5-871d141ed6-f52a158a1c.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/aria-query-npm-5.3.0-76575ac83b-c3e1ed127c.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/async-function-npm-1.0.0-a81667ebcd-1a09379937.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/async-generator-function-npm-1.0.0-14cf981d13-3d49e7acbe.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/asynckit-npm-0.4.0-c718858525-3ce727cbc7.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/brace-expansion-npm-5.0.4-acb9332524-cfd57e20d8.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/combined-stream-npm-1.0.8-dc14d4a63a-2e969e637d.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/cssom-npm-0.3.8-a9291d36ff-49eacc8807.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/cssom-npm-0.5.0-44ab2704f2-b502a315b1.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/cssstyle-npm-2.3.0-b5d112c450-46f7f05a15.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/data-urls-npm-3.0.2-c8b2050319-033fc3dd0f.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/decimal.js-npm-10.6.0-a72c1b8a2f-c0d45842d4.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/delayed-stream-npm-1.0.0-c5a4c4cc02-46fe6e83e2.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/dequal-npm-2.0.3-53a630c60e-6ff05a7561.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/dom-accessibility-api-npm-0.5.16-d3e2310666-377b4a7f9e.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/domexception-npm-4.0.0-5093673f9b-4ed443227d.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/entities-npm-6.0.1-84692dab43-62af130720.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/es-set-tostringtag-npm-2.1.0-4e55705d3f-86814bf8af.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/esbuild-npm-0.27.3-85b6c20323-aa74b8d8a3.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/escodegen-npm-2.1.0-e0bf940745-47719a65b2.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/estraverse-npm-5.3.0-03284f8f63-37cbe6e9a6.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/esutils-npm-2.0.3-f865beafd5-b23acd2479.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/form-data-npm-4.0.5-c35fce815a-52ecd6e927.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/generator-function-npm-2.0.1-aed34a724a-eb7e7eb896.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/get-intrinsic-npm-1.3.1-2f734f40ec-bb579dda84.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/glob-npm-13.0.6-864eb0cece-201ad69e5f.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/has-tostringtag-npm-1.0.2-74a4800369-c74c5f5cee.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/html-encoding-sniffer-npm-3.0.0-daac3dfe41-707a812ec2.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/is-potential-custom-element-name-npm-1.0.1-f352f606f8-ced7bbbb64.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/jest-environment-jsdom-npm-29.7.0-0b72dd0e0b-23bbfc9bca.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/jsdom-npm-20.0.3-906a2f7005-a4cdcff5b0.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/mime-db-npm-1.52.0-b5371d6fd2-54bb60bf39.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/mime-types-npm-2.1.35-dd9ea9f3e2-89aa9651b6.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/minimatch-npm-10.2.4-11f0605299-aea4874e52.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/minipass-npm-7.1.3-b73a16498d-175e4d5e20.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/nwsapi-npm-2.2.23-aa3710d724-aa4a570039.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/parse5-npm-7.3.0-b0410074a3-b0e48be20b.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/path-scurry-npm-2.0.2-f10aa6a77e-2b4257422b.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/picocolors-npm-1.1.1-4fede47cf1-e1cf46bf84.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/pretty-format-npm-27.5.1-cd7d49696f-248990cbef.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/psl-npm-1.15.0-410584ca6b-5e7467eb51.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/punycode-npm-2.3.1-97543c420d-febdc4362b.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/querystringify-npm-2.2.0-4e77c9f606-46ab16f252.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/react-is-npm-17.0.2-091bbb8db6-73b36281e5.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/requires-port-npm-1.0.0-fd036b488a-878880ee78.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/rimraf-npm-6.1.3-409ea7254f-dd98ec2ad7.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/saxes-npm-6.0.0-31558949f5-97b50daf6c.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/symbol-tree-npm-3.2.4-fe70cdb75b-c09a00aadf.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/tough-cookie-npm-4.1.4-8293cc8bd5-75663f4e2c.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/tr46-npm-3.0.0-e1ae1ea7c9-b09a15886c.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/universalify-npm-0.2.0-9984e61c10-e86134cb12.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/url-parse-npm-1.5.10-64fa2bcd6d-c9e96bc8c5.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/w3c-xmlserializer-npm-4.0.0-f09d0ec3fc-9a00c412b5.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/webidl-conversions-npm-7.0.0-e8c8e30c68-4c4f65472c.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/whatwg-encoding-npm-2.0.0-d7451f51b4-162d712d88.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/whatwg-mimetype-npm-3.0.0-5b617710c1-96f9f628c6.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/whatwg-url-npm-11.0.0-073529d93a-dfcd51c6f4.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/ws-npm-8.19.0-c967c046a5-26e4901e93.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/xml-name-validator-npm-4.0.0-0857c21729-f9582a3f28.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/xmlchars-npm-2.2.0-8b78f0f5e4-4ad5924974.zipis excluded by!**/.yarn/**,!**/*.zipyarn.lockis excluded by!**/yarn.lock,!**/*.lock
📒 Files selected for processing (8)
.changeset/add-plugin-blocker.md.pnp.cjsextensions/plugin-blocker/esbuild.config.jsextensions/plugin-blocker/package.jsonextensions/plugin-blocker/src/blockerPlugin.spec.tsxextensions/plugin-blocker/src/blockerPlugin.tsextensions/plugin-blocker/src/index.tsextensions/plugin-blocker/tsconfig.json
| "build:dts": "tsc --emitDeclarationOnly", | ||
| "build:js": "node ./esbuild.config.js", | ||
| "clean": "rimraf dist", | ||
| "dev": "yarn build:js --watch && yarn build:dts --watch", |
There was a problem hiding this comment.
Dev script won't run both watchers concurrently.
The && operator waits for the first command to exit before starting the second. Since --watch keeps build:js running indefinitely, build:dts --watch will never start.
🔧 Proposed fix using concurrent execution
- "dev": "yarn build:js --watch && yarn build:dts --watch",
+ "dev": "yarn build:js --watch & yarn build:dts --watch",Alternatively, consider using a tool like concurrently or npm-run-all for more robust parallel execution with proper signal handling.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "dev": "yarn build:js --watch && yarn build:dts --watch", | |
| "dev": "yarn build:js --watch & yarn build:dts --watch", |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@extensions/plugin-blocker/package.json` at line 30, The dev script in
package.json uses "&&" so "build:dts --watch" never starts because "build:js
--watch" never exits; change the "dev" script to run both watchers in parallel
by using a concurrent runner (e.g., add "concurrently" or "npm-run-all" as a
devDependency) and update the "dev" script to invoke both "build:js --watch" and
"build:dts --watch" concurrently (optionally with flags to kill others on exit
for proper signal handling). Ensure the package.json scripts reference the
existing "build:js" and "build:dts" script names and add the chosen tool to
devDependencies so CI/local runs work consistently.
문제
모바일 웹뷰 기반 서비스 팀들이 "화면 이탈 방지 UX"(뒤로가기 시 확인 다이얼로그 등)를 각자 Stackflow 바깥에서 구현하고 있다.
history.block(), 네이티브 브릿지 등 외부 API에 의존하다 보니connectBackButtonsPlugin도입 시 충돌이 발생하고, 동일 문제를 여러 팀이 중복 해결하고 있다.Stackflow 코어에는
onBefore*+preventDefault()메커니즘이 있지만, 개별 액티비티 컴포넌트가 "나로부터의 이탈을 막아줘"라고 선언할 수 있는 인터페이스가 없었다.해결
@stackflow/plugin-blocker패키지를 추가한다.useBlocker({ shouldBlock, onBlocked })훅으로 액티비티 컴포넌트가 네비게이션 차단 정책을 선언한다:shouldBlock(action): 차단 여부를 판단하는 predicate. pop뿐 아니라 push, replace, step 계열 모두 지원.onBlocked(nav, { proceed }): 차단 시 호출되는 콜백. 확인 다이얼로그 등 UX를 자유롭게 구현하고,proceed()로 네비게이션을 허용.Blocking set 모델: 여러 블로커가 동시에 차단할 수 있고, 모든 블로커가
proceed()해야 네비게이션이 실행된다.구현 세부사항
proceed()후 액션을 재실행할 때, 글로벌 boolean 플래그 대신 action params에 인스턴스별 Symbol 마커를 주입하여 replay 여부를 식별. 다른 플러그인이onBefore*훅에서 별도 네비게이션을 실행해도 해당 네비게이션이 blocker 판단을 우회하지 않음.onBlocked내에서 새 네비게이션이 발생하면 큐에 적재하여 재진입 없이 발생 순서대로 알림 (depth-first → breadth-first)onBlocked가 throw해도 다른 블로커의 알림은 정상 실행.blockerPlugin({ onError })옵션으로 커스텀 에러 처리 가능proceed는 여전히 호출 가능테스트
37개 테스트 케이스:
🤖 Generated with Claude Code