Skip to content

fix: P0/P1 SSR double-end, Electron-safe router, store tearing - #12

Merged
Amiya167 merged 5 commits into
mainfrom
fix/ssr-router-store
Apr 25, 2026
Merged

fix: P0/P1 SSR double-end, Electron-safe router, store tearing#12
Amiya167 merged 5 commits into
mainfrom
fix/ssr-router-store

Conversation

@Amiya167

@Amiya167 Amiya167 commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Summary

修复 Logos Workstation 集成时发现的 3 个阻塞问题:

P0

  • SSR renderToStream 双重 end()pipe(writable) 默认在 React 流结束时调用 writable.end(),随后 onAllReady 里再 write(afterContent) + end() 会抛 ERR_STREAM_WRITE_AFTER_END</div></body></html> 永远写不出去。插 PassThrough 桥接,bridge.pipe(writable, { end: false }) 保留 backpressure,bridge 'end' 再手动写 afterContent。
  • Electron 下只有 BrowserRouter — 打包渲染进程通过 file:// 加载 index.htmlBrowserRouter 刷新/深链 404(无 SPA fallback)。新增 ChenHashRouterChenAdaptiveRoutermode: 'browser' | 'hash' | 'auto'autofile: 协议下自动走 hash)。

P1

  • createStore tearing — 旧实现 useReducer + stateRef (render 阶段写) + useEffect 通知订阅者,并发模式 / StrictMode 下 state 写入与通知时机错位可能产生 tearing。改为每个 StoreProvider 实例挂一份 makeExternalStore,state 完全脱离 React render/commit 周期。
  • getSnapshot 不稳定useStoreSelector / createSimpleStore.useStore(selector) / useGlobalStore(store, selector) 三个路径之前每次调用 selector 都返回新引用,触发 React 18 的 "The result of getSnapshot should be cached" 警告、并可能在某些 selector 下死循环。用 lastRef 缓存 {state, selected} + equalityFn 参数(默认 Object.is),相等时返回上次的引用。
  • selector 重载useGlobalStorecreateSimpleStore.useStore 新增可选 selector / equalityFn,允许切片订阅。Logos workspaceStore 在 Monaco 打字时能显著减少重渲染。

Test plan

  • npx tsc --noEmit 干净
  • npx vitest run 62/62 通过(覆盖 createStore / createSimpleStore / useGlobalStore 既有用例)
  • npm run build 成功产出 dist/
  • Logos workstation 侧 ChenAdaptiveRouter 打包测试(在 Logos PR 中验)
  • 手动验证 SSR renderToStream 末尾 </body></html> 真的被写出(后续集成测试)

Follow-ups (P2, 下一轮 PR)

  • SSR:StaticHandlerContext.status / <Navigate> 映射 HTTP 状态码
  • SSR:loader / action / data router 支持
  • 文件路由:handleHotUpdate 别对所有 pages/** 改动 full-reload,尽量保留路由边界的 HMR
  • 文件路由:Windows 反斜杠路径 startsWith(pagesDir) 规范化
  • CLI:--install 自动装依赖
  • persist middleware(Logos 现状是每个 store 手写 localStorage)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Adaptive router with auto-detection to choose browser or hash routing.
  • Improvements

    • Store hooks support selector-based subscriptions with optional equality for stable updates.
    • Simplified dispatch usage in store hooks.
    • Render streams now use a bridged stream and expose an explicit abort mechanism for safer completion and error forwarding.
    • Server-side rendering: more robust stream/error handling and controlled shell emission.
  • Tests

    • New test suites covering router selection, SSR streaming behavior, and store selector/equality semantics.

- ssr/renderToStream: pipe+writable.end() 双重结束会抛 ERR_STREAM_WRITE_AFTER_END,
  afterContent (</div></body></html>) 永远写不出去。插入 PassThrough 桥接,用
  bridge.pipe(writable, { end: false }) 保留 backpressure,bridge 'end' 再手动写
  afterContent + writable.end()
- router: 仅 re-export BrowserRouter 作为 ChenRouter,Electron 打包渲染进程走
  file:// 时刷新/深链会 404。新增 ChenHashRouter 导出与 ChenAdaptiveRouter 工厂
  (mode: 'browser' | 'hash' | 'auto'),默认 auto 在 file: 协议下自动切 hash
- hooks/use-store:
  * createStore 旧实现 useReducer + stateRef (render 阶段写) + useEffect 通知,
    并发模式下订阅者看到的状态与 React commit 时机错位,会产生 tearing。
    改为每个 Provider 挂一份 makeExternalStore —— state 完全脱离 React render
  * useStoreSelector / useGlobalStore(selector) / createSimpleStore useStore(selector)
    都缓存 lastRef,getSnapshot 在相等语义下返回稳定引用,消除
    "getSnapshot should be cached" 警告与潜在死循环
  * useGlobalStore / useGlobalStoreDispatch 增加 selector 重载

62/62 vitest 通过,tsc 干净。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Replaces React-managed store internals with per-provider closure-based external stores supporting selectors and equality functions; adds an adaptive router (ChenAdaptiveRouter / ChenHashRouter) that selects router by mode or protocol; changes SSR streaming to pipe through a PassThrough bridge with centralized lifecycle, error forwarding, and abort exposure.

Changes

Cohort / File(s) Summary
Store System Refactor
src/hooks/use-store.tsx
Replaces internal React reducer/ref/effect store with per-StoreProvider makeExternalStore (getState/setState/subscribe). Adds optional equalityFn (default Object.is) and cached selection snapshots for useStoreSelector, useStore, and useGlobalStore. Simplifies dispatch hooks to return store.setState directly and exposes overloaded selector-aware APIs.
Router Enhancement & Tests
src/router/index.ts, src/__tests__/router.test.tsx
Adds ChenHashRouter alias and ChenAdaptiveRouter with `mode: 'browser'
SSR Stream Pipeline & Tests
src/ssr/index.ts, src/__tests__/ssr.test.ts
Streams React renderToPipeableStream output into a PassThrough bridge, forwards bridge output to caller writable, defers writing shell.afterContent until bridge end, centralizes end/abort handling, forwards bridge/writable errors to options.onError, and exposes React abort on writable.__chenAbort. Adds comprehensive streaming tests.
Store Tests Expansion
src/__tests__/use-store.test.tsx
Extends tests for selector overloads, equalityFn behavior, provider isolation, createSimpleStore selector overloads, and dispatch stability for both context-backed and global stores.

Sequence Diagram(s)

sequenceDiagram
  participant Caller as Caller\nWritable
  participant React as React\nrenderToPipeableStream
  participant Bridge as PassThrough\nBridge Stream
  participant Writable as CallerWritable\n(writable)
  participant Options as Options\n(onAllReady/onError)

  Caller->>React: renderToStream(writable, options)
  React->>Bridge: pipe rendered chunks into Bridge
  Bridge->>Writable: forward chunks to caller writable
  React->>React: onAllReady (do not write afterContent)
  Bridge->>Bridge: emit "end"
  Bridge->>Writable: write shell.afterContent
  Bridge->>Writable: end writable
  Bridge->>Options: invoke onAllReady()
  Bridge->>Options: on error -> onError(err) & destroy Writable
  Writable->>Options: on error -> onError(err) & destroy Bridge
  Caller->>Writable: call __chenAbort -> React.abort called
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through stores with selectors bright,

routers choosing paths by day or night.
A bridge streams whispers, errors kept kind,
abort at the ready, snapshots aligned —
a rabbit twitches, code neatly trimmed ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly addresses the three main blocking issues (P0/P1) fixed in the PR: SSR stream double-end, Electron-safe router with hash support, and store tearing/selector stability.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ssr-router-store

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/ssr/index.ts (1)

115-116: Consider a cleaner API for exposing abort.

Attaching __chenAbort as an untyped property works but requires callers to know about this undocumented extension. Consider returning an object with the abort function, or documenting this in the types.

♻️ Alternative: Return abort handle or use documented interface

Option 1 — Return an object (breaking change):

export function renderToStream(
  element: ReactElement,
  writable: Writable,
  options: RenderToStreamOptions,
): { abort: () => void } {
  // ... existing logic ...
  return { abort };
}

Option 2 — Document the property in a type:

export interface ChenWritable extends Writable {
  __chenAbort?: () => void;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ssr/index.ts` around lines 115 - 116, Replace the ad-hoc untyped cast for
__chenAbort with a documented type: declare and export an interface ChenWritable
extends Writable { __chenAbort?: () => void } and change the renderToStream
signature to accept writable: ChenWritable (or cast to ChenWritable internally)
so setting (writable.__chenAbort = abort) is typed; ensure the exported type
name ChenWritable and the abort function name are used so callers can rely on
the documented API instead of the magic __chenAbort property.
src/hooks/use-store.tsx (1)

122-136: Consider extracting shared selector-caching logic.

The same caching pattern (check state identity → check selected equality → update cache) is repeated in useStoreSelector, createSimpleStore.useStore, and useGlobalStore. After fixing the conditional hooks issue, consider extracting a shared helper to reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/use-store.tsx` around lines 122 - 136, Extract the repeated
selector-caching logic into a shared helper (e.g., createSelectorCache or
useSelectorCache) and replace the duplicated implementations in getSelected,
useStoreSelector, createSimpleStore.useStore, and useGlobalStore with calls to
that helper; the helper should accept the store.getState (or current state),
selector, and equalityFn, maintain an internal lastRef { state, selected }
cache, perform the identity check (lastRef.current.state === state), equality
check (equalityFn(lastRef.current.selected, next)), and update/return the cached
selected value accordingly so all four locations reuse the same deterministic
caching logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/use-store.tsx`:
- Around line 209-235: The hooks (useRef and useCallback) must be called
unconditionally to avoid violating React's rules of hooks: move the useRef
initialization (lastRef) and the useCallback definition (getSelected) above the
if (!selector) branch, and inside getSelected guard on selector before calling
it (or use a default identity selector) so the callback is safe when selector is
undefined; then keep the early return for the no-selector case using
useSyncExternalStore(store.subscribe, store.getState, store.getState) and for
the selector case return useSyncExternalStore(store.subscribe, getSelected,
getSelected) — referencing useGlobalStore, lastRef (useRef), getSelected
(useCallback), selector, and useSyncExternalStore.
- Around line 157-180: The hook calls inside useStore must be executed
unconditionally to satisfy React's rules of hooks: move the useRef (lastRef) and
useCallback (getSelected) declarations so they run on every render before the
early return; make getSelected handle the case where selector is undefined by
returning the full store.getState() (typed to R) when no selector is provided;
then keep the early return that returns useSyncExternalStore(store.subscribe,
store.getState, store.getState) or call useSyncExternalStore(store.subscribe,
getSelected, getSelected) consistently, ensuring the order of hook calls
(useRef/useCallback/useSyncExternalStore) is identical regardless of whether
selector is passed.

In `@src/ssr/index.ts`:
- Around line 90-98: The bridge end handler can throw if writable was already
destroyed and writable may emit errors itself; update the bridge.on('end')
handler to first check writable.destroyed or writable.writableEnded before
calling writable.write/shell.afterContent and wrap the write/end in a try/catch
that calls options.onError?.(err) and ensures writable.destroy(err) if the write
fails, and add a writable.on('error', (err) => { options.onError?.(err);
bridge.destroy?.(err) }) handler to propagate independent writable errors back
to the bridge cleanup logic; reference the existing bridge.on('end'),
bridge.on('error'), writable.write, writable.end, writable.destroy, and
options.onError/onAllReady to locate where to apply these changes.

---

Nitpick comments:
In `@src/hooks/use-store.tsx`:
- Around line 122-136: Extract the repeated selector-caching logic into a shared
helper (e.g., createSelectorCache or useSelectorCache) and replace the
duplicated implementations in getSelected, useStoreSelector,
createSimpleStore.useStore, and useGlobalStore with calls to that helper; the
helper should accept the store.getState (or current state), selector, and
equalityFn, maintain an internal lastRef { state, selected } cache, perform the
identity check (lastRef.current.state === state), equality check
(equalityFn(lastRef.current.selected, next)), and update/return the cached
selected value accordingly so all four locations reuse the same deterministic
caching logic.

In `@src/ssr/index.ts`:
- Around line 115-116: Replace the ad-hoc untyped cast for __chenAbort with a
documented type: declare and export an interface ChenWritable extends Writable {
__chenAbort?: () => void } and change the renderToStream signature to accept
writable: ChenWritable (or cast to ChenWritable internally) so setting
(writable.__chenAbort = abort) is typed; ensure the exported type name
ChenWritable and the abort function name are used so callers can rely on the
documented API instead of the magic __chenAbort property.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 918693bd-1c08-4f20-9884-29040790b725

📥 Commits

Reviewing files that changed from the base of the PR and between 71bd95e and 5f4a1bc.

📒 Files selected for processing (3)
  • src/hooks/use-store.tsx
  • src/router/index.ts
  • src/ssr/index.ts

Comment thread src/hooks/use-store.tsx
Comment thread src/hooks/use-store.tsx
Comment thread src/ssr/index.ts

@zhangqiling-1103 zhangqiling-1103 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看起来有大问题

@coderabbitai autofix

@zhangqiling-1103

Copy link
Copy Markdown
Collaborator

@coderabbitai autofix

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 3 file(s) based on 3 unresolved review comments.

Files modified:

  • package-lock.json
  • src/hooks/use-store.tsx
  • src/ssr/index.ts

Commit: f9f2e90b2054565c2d69333574cdee703a5697ac

The changes have been pushed to the fix/ssr-router-store branch.

Time taken: 2m 45s

Fixed 3 file(s) based on 3 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/use-store.tsx`:
- Around line 122-138: The cache currently keys only off state identity causing
stale selections when the selector function changes; update the caching shape
used by lastRef in use-store.tsx (and the identical caches in
createSimpleStore.useStore and useGlobalStore) to include the selector identity
(e.g., change lastRef to hold { state: T; selected: R; selector: SelectorType }
| null), then in getSelected compare both lastRef.current.state === state AND
lastRef.current.selector === selector before returning the cached selected; if
the selector differs, recompute next = selector(state) (and still apply
equalityFn to possibly reuse the old selected), and always update lastRef with
the current selector when storing { state, selected, selector } so selector
changes no longer cause stale values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3117abfa-f7cc-4b33-8d0e-d0d6848058a6

📥 Commits

Reviewing files that changed from the base of the PR and between 5f4a1bc and f9f2e90.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • src/hooks/use-store.tsx
  • src/ssr/index.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/ssr/index.ts

Comment thread src/hooks/use-store.tsx Outdated
@zhangqiling-1103

Copy link
Copy Markdown
Collaborator

@coderabbitai autofix

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 1 file(s) based on 1 unresolved review comment.

Files modified:

  • src/hooks/use-store.tsx

Commit: 698fcbcd9e7e70b7a34141f8a9691fdf5f403d29

The changes have been pushed to the fix/ssr-router-store branch.

Time taken: 1m 32s

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/hooks/use-store.tsx (1)

155-186: Refactor: dead branch inside getSelected and conditional useSyncExternalStore can be unified.

Lines 166–169 (if (!selector) return state as unknown as R) are unreachable because the early return at line 182 short-circuits before getSelected is ever invoked when no selector is provided. While the current code is functionally correct (hook order is preserved across renders since both branches end in exactly one useSyncExternalStore call), the conditional return with a hook call inside also tends to trip eslint-plugin-react-hooks despite being safe.

Unifying into a single snapshot path removes the dead cast and avoids the false-positive lint shape:

♻️ Proposed refactor
   function useStore<R>(
     selector?: (state: T) => R,
     equalityFn: (a: R, b: R) => boolean = Object.is,
   ): T | R {
-    // 闭包缓存,稳定 getSnapshot 引用
-    // 必须在所有分支前调用 hooks,满足 Rules of Hooks
     const lastRef = useRef<{ state: T; selected: R; selector: (state: T) => R } | null>(null);
-    const getSelected = useCallback((): R => {
+    const getSnapshot = useCallback((): T | R => {
       const state = store.getState();
       if (!selector) {
-        // selector 未传时,返回完整 state(需类型断言为 R)
-        return state as unknown as R;
+        return state;
       }
       if (lastRef.current && lastRef.current.state === state && lastRef.current.selector === selector) {
         return lastRef.current.selected;
       }
       const next = selector(state);
       if (lastRef.current && equalityFn(lastRef.current.selected, next)) {
         lastRef.current = { state, selected: lastRef.current.selected, selector };
         return lastRef.current.selected;
       }
       lastRef.current = { state, selected: next, selector };
       return next;
     }, [selector, equalityFn]);
 
-    if (!selector) {
-      return useSyncExternalStore(store.subscribe, store.getState, store.getState);
-    }
-    return useSyncExternalStore(store.subscribe, getSelected, getSelected);
+    return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
   }

The same simplification applies to useGlobalStore (lines 215–246).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/use-store.tsx` around lines 155 - 186, The getSelected function
contains a dead branch (if (!selector) return state as unknown as R) because
when selector is falsy the hook already calls useSyncExternalStore with
store.getState directly; to fix, remove that unreachable return and instead make
getSelected always return a value by treating a missing selector as the identity
selector (i.e., return store.getState() cast to R), then call
useSyncExternalStore exactly once with getSelected for both client and server
snapshots; apply the same refactor to useGlobalStore (ensure lastRef /
selector/equalityFn logic still works when selector is undefined).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/hooks/use-store.tsx`:
- Around line 155-186: The getSelected function contains a dead branch (if
(!selector) return state as unknown as R) because when selector is falsy the
hook already calls useSyncExternalStore with store.getState directly; to fix,
remove that unreachable return and instead make getSelected always return a
value by treating a missing selector as the identity selector (i.e., return
store.getState() cast to R), then call useSyncExternalStore exactly once with
getSelected for both client and server snapshots; apply the same refactor to
useGlobalStore (ensure lastRef / selector/equalityFn logic still works when
selector is undefined).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 19a3f248-9e67-43fd-84cc-2f2b7f604ba9

📥 Commits

Reviewing files that changed from the base of the PR and between f9f2e90 and 698fcbc.

📒 Files selected for processing (1)
  • src/hooks/use-store.tsx

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Note

Unit test generation is a public access feature. Expect some limitations and changes as we gather feedback and continue to improve it.


Generating unit tests... This may take up to 20 minutes.

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

✅ Unit tests committed locally. Commit: 14f49cd81d149a3f8f8f473b147ecda9e1165d8b

Two tests created separate `renderHook` calls with the same wrapper, which
spawned independent `StoreProvider` instances. Dispatch wrote to one store
while the selector read from another, so the failing test never observed the
update and the passing one stayed green for the wrong reason. Combine the
hooks into a single `renderHook` so they share one Provider, and pass
`children` via the props object to satisfy `StoreProvider`'s typing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/__tests__/use-store.test.tsx (1)

193-222: Render-count assertion is correct but fragile under StrictMode.

The test depends on useSyncExternalStore skipping a re-render because getSelected returns the cached selected reference (Object.is) when equalityFn reports equality — this is consistent with the lastRef logic in useStoreSelector (per snippet) and should pass deterministically as configured.

However, the assertion expect(renderCount).toBe(initialRenders) is sensitive to renderer settings: if renderHook is ever wrapped in <StrictMode> (or a default test setup adds it), the body would execute twice per logical render and the strict equality assertion could flake. Consider a >=/<= style or asserting "no additional renders after dispatch" via a delta captured immediately before act(...) — which is already what initialRenders does, but toBeLessThanOrEqual(initialRenders) would be more robust to future StrictMode adoption.

♻️ Optional tightening
-    expect(renderCount).toBe(initialRenders);
+    // No extra renders after dispatch (robust to StrictMode double-invocation).
+    expect(renderCount).toBeLessThanOrEqual(initialRenders);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/use-store.test.tsx` around lines 193 - 222, The test's strict
render-count assertion can flake under React StrictMode; change the final
assertion to allow equal or fewer renders rather than exact equality: after
capturing initialRenders, perform the dispatch and then assert renderCount <=
initialRenders (or assert that renderCount - initialRenders === 0 using a delta
check) so the check in the useStoreSelector test (referencing renderCount,
initialRenders, and the dispatch returned by objStore.useStoreDispatch()) is
resilient to StrictMode double-invocations.
src/__tests__/ssr.test.ts (1)

98-104: Test leaks an in-flight stream.

This test calls renderToStream and asserts synchronously without awaiting waitForFinish(writable). The React stream → bridge → writable pipeline keeps running after the test resolves; the eventual writable.end() and any onAllReady/error events fire after Vitest has moved on. While this currently doesn't fail, it can produce flaky logs and "operation after test ended" warnings, and any future change that surfaces errors during shell rendering could cause an unhandled rejection in the wrong test.

Suggested cleanup
-  it('exposes __chenAbort on the writable', () => {
+  it('exposes __chenAbort on the writable', async () => {
     const { writable } = collectWritable();

     renderToStream(React.createElement('div'), writable, baseOptions);

     expect(typeof (writable as Writable & { __chenAbort?: () => void }).__chenAbort).toBe('function');
+    await waitForFinish(writable);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/ssr.test.ts` around lines 98 - 104, The test starts a streaming
render with renderToStream and then asserts synchronously, which leaks an
in-flight stream; update the test (the collectWritable/writable usage) to wait
for the stream to finish before asserting or teardown by awaiting
waitForFinish(writable) (or otherwise ending the writable) after calling
renderToStream and before completing the test so the pipeline (renderToStream →
bridge → writable) has fully settled and will not emit events after the test
ends.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/__tests__/router.test.tsx`:
- Around line 22-32: The test captures originalProtocol but never restores
window.location, and some tests replace the real Location with a plain object
(via Object.defineProperty(window, 'location', ...)), leaking state; change the
setup to save the entire original location object (e.g., const originalLocation
= window.location in beforeEach) and in afterEach restore it by calling
Object.defineProperty(window, 'location', { value: originalLocation,
configurable: true }) (or otherwise reassign the original Location object) and
keep vi.restoreAllMocks(); update references to originalProtocol to use
originalLocation so any place that replaced window.location is reverted to the
genuine Location instance.
- Around line 52-138: The tests for ChenAdaptiveRouter only assert child
rendering and don't verify whether BrowserRouter or HashRouter was chosen;
update the tests to assert the actual router branch: either (A) mock and spy the
underlying components (BrowserRouter, HashRouter) with vi.mock and assert
BrowserRouter/HashRouter were called or not in each case (refer to tests using
ChenAdaptiveRouter in src/__tests__/router.test.tsx and the test cases labeled
mode="auto"/"hash"/default), or (B) render a small useLocation/useNavigate-aware
child component and assert observable routing differences (e.g.,
window.location.hash populated in hash mode but empty in browser mode) to prove
the correct router was used; pick one approach and replace the current
container.textContent assertions with the appropriate router-specific
expectations.

In `@src/__tests__/ssr.test.ts`:
- Around line 185-218: The test currently asserts writable.destroyed after
explicitly calling writable.destroy, which is tautological; instead, emit an
error on the writable and assert the bridge error path actually invoked the
onError callback and caused bridge destruction. Change the test to spy
on/replace the PassThrough instance's destroy method (or wrap it with a spy),
call writable.emit('error', err) rather than directly destroying it, then await
completion and assert vi.fn onError was calledWith(err) and that the spy for
writable.destroy was called (or writable.destroyed is true as a result of the
bridge error handler). Reference renderToStream, onError, PassThrough/writable
and the bridge destroy path in src/ssr/index.ts when making the change.
- Around line 154-183: The test currently resolves as soon as the writable is
closed/errored and thus can pass without exercising the guard (if
(writable.destroyed || writable.writableEnded) return;) inside the bridge 'end'
handler; change the test to wait for the bridge to finish before asserting by
either (A) making renderToStream return or expose a promise that resolves when
the internal bridge emits 'end' (update renderToStream to resolve after
bridge.on('end') runs) or (B) if you prefer to keep the public API, wait for a
microtask flush plus an explicit signal from the stream end (e.g., wait for
nextTick/setImmediate and for onError/unhandledRejection to settle) before
asserting that onAllReady was not called; target symbols: renderToStream,
onShellReady, onAllReady, onError and the guard using writable.destroyed /
writable.writableEnded in the bridge 'end' handler so the test reliably
exercises that guard.

---

Nitpick comments:
In `@src/__tests__/ssr.test.ts`:
- Around line 98-104: The test starts a streaming render with renderToStream and
then asserts synchronously, which leaks an in-flight stream; update the test
(the collectWritable/writable usage) to wait for the stream to finish before
asserting or teardown by awaiting waitForFinish(writable) (or otherwise ending
the writable) after calling renderToStream and before completing the test so the
pipeline (renderToStream → bridge → writable) has fully settled and will not
emit events after the test ends.

In `@src/__tests__/use-store.test.tsx`:
- Around line 193-222: The test's strict render-count assertion can flake under
React StrictMode; change the final assertion to allow equal or fewer renders
rather than exact equality: after capturing initialRenders, perform the dispatch
and then assert renderCount <= initialRenders (or assert that renderCount -
initialRenders === 0 using a delta check) so the check in the useStoreSelector
test (referencing renderCount, initialRenders, and the dispatch returned by
objStore.useStoreDispatch()) is resilient to StrictMode double-invocations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 60f7b9ee-410b-428e-96e0-c3730a6a576f

📥 Commits

Reviewing files that changed from the base of the PR and between 698fcbc and f473c3d.

📒 Files selected for processing (3)
  • src/__tests__/router.test.tsx
  • src/__tests__/ssr.test.ts
  • src/__tests__/use-store.test.tsx

Comment on lines +22 to +32
let originalProtocol: string;

beforeEach(() => {
// Store original location descriptor
originalProtocol = window.location.protocol;
});

afterEach(() => {
vi.restoreAllMocks();
// Restore window.location.protocol if it was changed
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

afterEach doesn't actually restore window.location; originalProtocol is captured but never read.

Object.defineProperty(window, 'location', { value: { ...window.location, protocol }, writable: true, configurable: true }) replaces the real Location instance with a plain object. vi.restoreAllMocks() does not undo this, and the comment on Line 31 ("Restore window.location.protocol if it was changed") is followed by no code. This leaks state into subsequent tests in this file (and any later test in the same worker that touches window.location), making results order-dependent.

🛠️ Suggested fix
 describe('ChenAdaptiveRouter', () => {
-  let originalProtocol: string;
-
-  beforeEach(() => {
-    // Store original location descriptor
-    originalProtocol = window.location.protocol;
-  });
-
-  afterEach(() => {
-    vi.restoreAllMocks();
-    // Restore window.location.protocol if it was changed
-  });
+  let originalLocationDescriptor: PropertyDescriptor | undefined;
+
+  beforeEach(() => {
+    originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
+  });
+
+  afterEach(() => {
+    if (originalLocationDescriptor) {
+      Object.defineProperty(window, 'location', originalLocationDescriptor);
+    }
+    vi.restoreAllMocks();
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/router.test.tsx` around lines 22 - 32, The test captures
originalProtocol but never restores window.location, and some tests replace the
real Location with a plain object (via Object.defineProperty(window, 'location',
...)), leaking state; change the setup to save the entire original location
object (e.g., const originalLocation = window.location in beforeEach) and in
afterEach restore it by calling Object.defineProperty(window, 'location', {
value: originalLocation, configurable: true }) (or otherwise reassign the
original Location object) and keep vi.restoreAllMocks(); update references to
originalProtocol to use originalLocation so any place that replaced
window.location is reverted to the genuine Location instance.

Comment on lines +52 to +138
it('mode="hash" renders HashRouter content regardless of protocol', () => {
Object.defineProperty(window, 'location', {
value: { ...window.location, protocol: 'http:' },
writable: true,
configurable: true,
});

const { container } = render(
React.createElement(
ChenAdaptiveRouter,
{ mode: 'hash' },
React.createElement('div', null, 'hash-content')
)
);
expect(container.textContent).toContain('hash-content');
});

it('mode="auto" with http: protocol uses BrowserRouter', () => {
Object.defineProperty(window, 'location', {
value: { ...window.location, protocol: 'http:' },
writable: true,
configurable: true,
});

const { container } = render(
React.createElement(
ChenAdaptiveRouter,
{ mode: 'auto' },
React.createElement('div', null, 'auto-http-content')
)
);
expect(container.textContent).toContain('auto-http-content');
});

it('mode="auto" with file: protocol uses HashRouter', () => {
Object.defineProperty(window, 'location', {
value: { ...window.location, protocol: 'file:' },
writable: true,
configurable: true,
});

const { container } = render(
React.createElement(
ChenAdaptiveRouter,
{ mode: 'auto' },
React.createElement('div', null, 'auto-file-content')
)
);
expect(container.textContent).toContain('auto-file-content');
});

it('default mode (auto) with https: protocol uses BrowserRouter', () => {
Object.defineProperty(window, 'location', {
value: { ...window.location, protocol: 'https:' },
writable: true,
configurable: true,
});

const { container } = render(
React.createElement(
ChenAdaptiveRouter,
{},
React.createElement('div', null, 'default-https')
)
);
expect(container.textContent).toContain('default-https');
});

it('resolves to hash when mode is "hash" even on http: protocol', () => {
Object.defineProperty(window, 'location', {
value: { ...window.location, protocol: 'http:' },
writable: true,
configurable: true,
});

// mode='hash' should always use HashRouter
// HashRouter uses window.location.hash, BrowserRouter uses window.history
// We verify it renders correctly — both work, but hash mode uses #-based routing
const { container } = render(
React.createElement(
ChenAdaptiveRouter,
{ mode: 'hash' },
React.createElement('span', null, 'forced-hash')
)
);
expect(container.textContent).toContain('forced-hash');
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These tests don't actually verify which router was selected.

Every mode="auto"|"hash"|"browser" test asserts only container.textContent.toContain(...), which is identical regardless of whether BrowserRouter or HashRouter was rendered (both unconditionally render their children). As written, ChenAdaptiveRouter could ignore mode entirely or always pick the wrong branch and these tests would still pass — so they don't protect the P0 fix described in the PR (Electron file:// deep-link 404).

To actually exercise the branch decision, assert something observable that differs between routers. Two practical options:

♻️ Option A — spy on the underlying router components via vi.mock
+vi.mock('react-router', async () => {
+  const actual = await vi.importActual<typeof import('react-router')>('react-router');
+  return {
+    ...actual,
+    BrowserRouter: vi.fn((props: any) => actual.BrowserRouter(props)),
+    HashRouter: vi.fn((props: any) => actual.HashRouter(props)),
+  };
+});

Then in each test assert e.g. expect(BrowserRouter).toHaveBeenCalled() / expect(HashRouter).not.toHaveBeenCalled().

♻️ Option B — render a useLocation-aware child and assert URL shape

Navigate via Link/useNavigate and assert window.location.hash is non-empty for hash mode and empty for browser mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/router.test.tsx` around lines 52 - 138, The tests for
ChenAdaptiveRouter only assert child rendering and don't verify whether
BrowserRouter or HashRouter was chosen; update the tests to assert the actual
router branch: either (A) mock and spy the underlying components (BrowserRouter,
HashRouter) with vi.mock and assert BrowserRouter/HashRouter were called or not
in each case (refer to tests using ChenAdaptiveRouter in
src/__tests__/router.test.tsx and the test cases labeled
mode="auto"/"hash"/default), or (B) render a small useLocation/useNavigate-aware
child component and assert observable routing differences (e.g.,
window.location.hash populated in hash mode but empty in browser mode) to prove
the correct router was used; pick one approach and replace the current
container.textContent assertions with the appropriate router-specific
expectations.

Comment thread src/__tests__/ssr.test.ts
Comment on lines +154 to +183
it('does not write afterContent if writable is already destroyed before bridge ends', async () => {
// This tests the guard: if (writable.destroyed || writable.writableEnded) return;
const onAllReady = vi.fn();
const onError = vi.fn();

// Create a writable that we'll destroy immediately after shell
const chunks: string[] = [];
const writable = new PassThrough();
writable.on('data', (d: Buffer) => chunks.push(d.toString()));

const onShellReady = vi.fn(() => {
// Destroy the writable right after shell is ready
writable.destroy(new Error('client disconnected'));
});

await new Promise<void>((resolve) => {
writable.on('close', resolve);
writable.on('error', () => resolve()); // swallow the error for test

renderToStream(React.createElement('div', null, 'content'), writable, {
...baseOptions,
onShellReady,
onAllReady,
onError,
});
});

// onAllReady should NOT have been called because writable was destroyed
expect(onAllReady).not.toHaveBeenCalled();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Race: this test can pass even without the destroyed/writableEnded guard.

The promise resolves on writable's close/error, which fires immediately after writable.destroy(...) inside onShellReady. At that moment the React → bridge piping is still in progress, so bridge.on('end') (where the guard and onAllReady?.() live) has not run yet. expect(onAllReady).not.toHaveBeenCalled() therefore passes simply because the bridge hasn't ended, regardless of whether the guard is present. Removing if (writable.destroyed || writable.writableEnded) return; from src/ssr/index.ts would not cause this test to fail — it would just throw ERR_STREAM_WRITE_AFTER_END asynchronously after the test has already resolved.

Consider awaiting the bridge end (or a microtask flush) before asserting, e.g. by exposing the bridge or by waiting on a longer tick window plus an onError/unhandledRejection check, so the guard is actually exercised.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/ssr.test.ts` around lines 154 - 183, The test currently
resolves as soon as the writable is closed/errored and thus can pass without
exercising the guard (if (writable.destroyed || writable.writableEnded) return;)
inside the bridge 'end' handler; change the test to wait for the bridge to
finish before asserting by either (A) making renderToStream return or expose a
promise that resolves when the internal bridge emits 'end' (update
renderToStream to resolve after bridge.on('end') runs) or (B) if you prefer to
keep the public API, wait for a microtask flush plus an explicit signal from the
stream end (e.g., wait for nextTick/setImmediate and for
onError/unhandledRejection to settle) before asserting that onAllReady was not
called; target symbols: renderToStream, onShellReady, onAllReady, onError and
the guard using writable.destroyed / writable.writableEnded in the bridge 'end'
handler so the test reliably exercises that guard.

Comment thread src/__tests__/ssr.test.ts
Comment on lines +185 to +218
it('calls onError callback when bridge encounters an error', async () => {
const onError = vi.fn();

const chunks: Buffer[] = [];
const writable = new PassThrough();
writable.on('data', (d: Buffer) => chunks.push(d));

// We'll manually emit an error on the writable to test bi-directional error propagation
let resolved = false;
const done = new Promise<void>((resolve) => {
writable.on('close', () => {
if (!resolved) { resolved = true; resolve(); }
});
writable.on('error', () => {
if (!resolved) { resolved = true; resolve(); }
});
});

renderToStream(React.createElement('div'), writable, {
...baseOptions,
onError,
});

// Emit an error on writable to trigger the error propagation path
process.nextTick(() => {
if (!writable.destroyed && !writable.writableEnded) {
writable.destroy(new Error('upstream error'));
}
});

await done;
// onError may or may not be called depending on timing, but writable should be destroyed
expect(writable.destroyed).toBe(true);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Assertion is tautological — doesn't actually verify bidirectional error propagation.

The test itself calls writable.destroy(new Error('upstream error')) and then asserts expect(writable.destroyed).toBe(true), which is true by construction. Combined with the comment "onError may or may not be called depending on timing", nothing in this test would fail if the writable.on('error', …)bridge.destroy(err) path in src/ssr/index.ts were removed.

Suggest asserting the actual propagation contract: onError is invoked with the error, and the bridge gets destroyed. For example, capture the bridge via a mocked PassThrough or inject a spy, or at minimum:

Proposed strengthening
-    // onError may or may not be called depending on timing, but writable should be destroyed
-    expect(writable.destroyed).toBe(true);
+    // Give the error handler a tick to run.
+    await new Promise((r) => setImmediate(r));
+    expect(onError).toHaveBeenCalledTimes(1);
+    expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'upstream error' }));
+    expect(writable.destroyed).toBe(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/ssr.test.ts` around lines 185 - 218, The test currently asserts
writable.destroyed after explicitly calling writable.destroy, which is
tautological; instead, emit an error on the writable and assert the bridge error
path actually invoked the onError callback and caused bridge destruction. Change
the test to spy on/replace the PassThrough instance's destroy method (or wrap it
with a spy), call writable.emit('error', err) rather than directly destroying
it, then await completion and assert vi.fn onError was calledWith(err) and that
the spy for writable.destroy was called (or writable.destroyed is true as a
result of the bridge error handler). Reference renderToStream, onError,
PassThrough/writable and the bridge destroy path in src/ssr/index.ts when making
the change.

@Amiya167
Amiya167 merged commit 7103c1c into main Apr 25, 2026
3 checks passed
@Amiya167
Amiya167 deleted the fix/ssr-router-store branch April 25, 2026 05:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants