Skip to content

fix: review follow-ups round 2 + issue triage (#247-#256) - #259

Merged
ximing merged 14 commits into
masterfrom
fix/review-followups-reactive
Sep 1, 2026
Merged

fix: review follow-ups round 2 + issue triage (#247-#256)#259
ximing merged 14 commits into
masterfrom
fix/review-followups-reactive

Conversation

@ximing

@ximing ximing commented Sep 1, 2026

Copy link
Copy Markdown
Owner

背景

两部分内容:(1) #257 合入后追加的一轮 review 修复(当时在原分支上提交推送,晚于 squash 合并,未进入 master);(2) 10 个 open issue 的 triage 与修复。全部经过验证 + TDD(先红后绿)。

Review 修复(round 2)

observer

react

  • view 类组件 commit 前不做依赖追踪:修复「render pass 被丢弃且永不 commit 时 reaction 永久泄漏在 store 并对死实例 forceUpdate」(类组件无法套用 FinalizationRegistry 兜底——泄漏子图 store→reaction→实例自持,只能从构造上消除)
  • arrow-field 生命周期字段重绑定componentDidMount = () => {} 实例字段遮蔽包装器原型方法导致的静默失活/泄漏
  • bindServices: deferToMicrotask 增加 setTimeout 最终兜底;提取 createContainer

Issue triage(#247#256,全部处理并关闭)

Issue 处理
#247 @memo 抛错后外层 observe 永久失效 ✅ 修复:has trap 预注册依赖
#248 batch 内读 @memo 拿到过期缓存 ✅ 修复:debugger 钩子同步失效记账
#249 useReaction effect 读取泄漏进依赖 ✅ 修复:effect untracked 执行
#250 @Debounce/@Throttle 分离调用 TypeError ✅ 修复:哨兵键降级
#251 数组变异方法包装先于 Proxy 不变式检查 ✅ 修复:不变式检查前置(base+shadow)
#252 bindServices 卸载销毁打死逃逸引用 ⛔ 关闭:设计使然,changeset 标注行为变化
#253 useReaction lazy 被静默丢弃 ✅ 修复:dev 警告 + JSDoc 标注
#254 view 构造期锁死 static rendering 标志 ✅ 已随 commit 前追踪重构修复 + 残余边缘修复
#255 cleanupAllMemos 不 notify ✅ 修复:notify 选项,destroy 保持静默
#256 deep Set keys 返回 proxy 键 ✅ 契约测试钉住 + README 文档(不改行为)

验证

  • observer 1279 / service 249 / react 233 全绿;三包构建通过
  • 新增回归测试约 25 条,每个修复均先红后绿

🤖 Generated with Claude Code

ximing and others added 13 commits September 1, 2026 00:22
- batch: guard the Error.cause assignment so a frozen/non-extensible
  callback error no longer throws a TypeError from finally that would
  replace the in-flight exception (the #212 masking all over again).
- batch: when the flush error cannot be attached (callback threw a
  non-Error, the Error already has a cause, or the object is frozen),
  console.warn instead of silently dropping the reaction's stack.
- observe: reviving an unobserved reaction now resets everRan, so a
  failing first run after revival takes the firstRun auto-detach path
  instead of restoring an empty snapshot and becoming a live reaction
  with zero dependencies (interaction regression between #215 and #233).
- runAsReaction: drop the per-rerun cleaners.slice() (releaseReaction
  never mutates the old array in place, so the reference is a valid
  snapshot) and the unreachable else-release branch.

Co-Authored-By: Claude Code <noreply@anthropic.com>
view-wrapped class components silently lost reactivity after any
componentWillUnmount that was not a real unmount — StrictMode's
simulated remount and Suspense/Offscreen hide both null the
_reactiveRender reaction, and nothing ever recreated it. Recreate in
componentDidMount (the remount path replays cDM without re-rendering)
and in render as a fallback; also treat an unobserved (first-run
auto-detached) reaction as dead.

useObserver gains the same unobserved-reaction guard as defense in
depth — empirically React 19 remounts mount-suspended fibers (no hook
state to preserve) and committed re-suspensions are healed by the
subscribe cleanup/recreate path, but a non-null dead reaction must
never be reused for tracking.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The committed-flag + queueMicrotask destroy only cancels StrictMode's
same-flush re-setup. <Activity>/Offscreen hide→reveal spans separate
commits, so the microtask destroys the container while the tree is
merely hidden and the useRef-preserved ADM never recreates it — after
reveal, children resolve against a null container. Rebuild the
container in place during render (Activity reveal re-renders the tree)
and re-arm the FinalizationRegistry backstop for the rebuilt container.
Trade-off, noted in code: service state is lost while hidden.

Also:
- guard queueMicrotask with a Promise.resolve().then fallback for old
  RN JSC/Hermes environments (same targets the FinalizationRegistry and
  WeakRef fallbacks exist for),
- drop the post-destroy registry re-register (provable no-op),
- drop the dead `timmer` ADM field.

Co-Authored-By: Claude Code <noreply@anthropic.com>
- batch: guard against self-referential cause when the callback and a
  reaction throw the identical Error instance (E.cause = E loops naive
  cause-chain walkers); fall through to the warn path instead
- batch: isolate console.warn itself so throwing warn shims (strict
  console harnesses) cannot replace the in-flight callback exception
- observe(revive): preserve a revived reaction's custom scheduler and
  debugger instead of silently overwriting them with global defaults
- reaction-runner: drop the redundant null encoding on prevCleaners
  (the restore branch is unreachable on firstRun)

Co-Authored-By: Claude Code <noreply@anthropic.com>
- deferToMicrotask: add a setTimeout final fallback so teardown still
  happens in legacy JS engines without Promise; document that
  setImmediate-style Promise polyfills degrade the timing guarantee
- extract createContainer so the hidden-tree rebuild no longer
  constructs a throwaway pseudo-ADM wrapper

Co-Authored-By: Claude Code <noreply@anthropic.com>
…lifecycles

- view class components no longer create/run the reaction before the
  first commit: a discarded render pass (sibling suspends, concurrent
  abort) never registers deps, so the reaction cannot leak permanently
  in the store and forceUpdate a dead instance. Class components cannot
  use the useObserver FinalizationRegistry backstop (the leaked
  subgraph store->reaction->instance is self-sustaining), so tracking
  starts in componentDidMount with one forced re-render to collect
  deps (synchronous before paint, invisible to the UI).
- arrow-function class fields (componentDidMount = () => {...}) are
  own instance properties that shadow the wrapper's prototype
  lifecycle methods: React invoked only the field, silently skipping
  reaction revival on StrictMode/Suspense remounts and reaction
  cleanup on unmount. After super() the fields are initialized, so the
  wrapper now rebinds them in place to run both the user's field and
  the wrapper logic.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…s-reactive

# Conflicts:
#	packages/observer/src/internals/reaction-runner.ts
#	packages/react/src/domain/bind.tsx
#	packages/react/src/view.tsx
- #247: pre-register the (instance, key) dependency through the has
  trap before computing, so a throwing getter no longer leaves outer
  observers with zero dependencies and permanently silent
- #248: invalidate the cache synchronously on the trigger path (the
  reaction's debugger hook, write-ops only) so mid-batch reads never
  see stale values; reuse the inner reaction across recomputations so
  a notify queued mid-batch is not dropped
- #255: cleanupAllMemos notifies outer observers (batched, single
  flush); Service.destroy passes { notify: false } to stay silent

Co-Authored-By: Claude Code <noreply@anthropic.com>
Detached calls (this null/undefined/primitive, e.g. arr.map(service.save)
or destructured methods) used to throw TypeError: Invalid value used as
weak map key after the per-instance WeakMap migration. They now share one
module-level sentinel state, matching the pre-WeakMap closure semantics.

Co-Authored-By: Claude Code <noreply@anthropic.com>
)

Reading a frozen (non-configurable + non-writable) own array mutator
returned the batch-wrapped function before the invariant check ran,
throwing TypeError. The wrap now happens after the invariant check in
the base handler; the shadow handler checks the descriptor only on the
narrow would-wrap path so ordinary reads pay nothing extra.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Deep mode returns proxy keys (keys === values per #192), shadow mode
returns raw members; raw() unwraps iterator-returned proxy keys.
Contract tests pin both behaviors and the README documents the
raw-collection interop caveat plus the raw() escape hatch.

Co-Authored-By: Claude Code <noreply@anthropic.com>
, #253)

The two-function form executed the effect inside the tracked callback,
so observables read by the effect leaked into the dependency set and
spuriously re-fired it. The effect now runs under an unobserved shield
reaction (public API only), matching MobX reaction semantics; the
fireImmediately first run is covered too. The single-function form now
warns in development when the silently dropped lazy option is passed.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The constructor early-returned under enableStaticRendering, skipping
the lifecycle-field rebinding; with an arrow-field componentDidMount
the wrapper's commit logic was then shadowed forever and the component
never became reactive after the flag was turned off. Rebinding creates
no reaction and is SSR-safe, so it now always runs.

Also adds the pending changesets for the earlier fixes on this branch.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…252)

Escaped service references becoming dead after unmount is intended —
the container is private to the bound subtree by design.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@ximing ximing changed the title fix: review follow-ups round 2 (observer/react) fix: review follow-ups round 2 + issue triage (#247-#256) Sep 1, 2026
@ximing
ximing merged commit 946f148 into master Sep 1, 2026
2 checks passed
ximing added a commit that referenced this pull request Sep 4, 2026
Keep the later review-round implementations; retain master's batch
self-cause guard from #259.
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.

1 participant