Skip to content

Widen time travel via store adapters - #19

Merged
Fausto95 merged 16 commits into
mainfrom
feat/time-travel-store-adapters
Aug 17, 2026
Merged

Fausto95 merged 16 commits into
mainfrom
feat/time-travel-store-adapters

Conversation

@Fausto95

@Fausto95 Fausto95 commented Aug 17, 2026 •

Copy link
Copy Markdown
Owner

Why

Time travel is the headline feature, but the README carried an asterisk: refs,
unregistered stores, production builds and unmounted components are not
restored. Most real apps keep their meaningful state in Zustand, Redux, Jotai or
TanStack Query — so for those apps the playhead rewound the leaves and left the
trunk live.

The adapter seam already existed in @reactlens/instrumentation, but it was
only reachable from the embedded runtime object, which the playground and the
e2e fixture construct. An app debugged through the Chrome extension had no way
to register anything at all
— the path almost every real user is on.

What changed

@reactlens/adapters (new published package) — zustandAdapter,
reduxAdapter + withTimeTravel, queryAdapter, and createStoreAdapter for
anything else (Jotai, module singletons). Zero dependency and zero peer
dependency on any store library: stores are matched structurally, and Query's
dehydrate/hydrate are passed in from the app's own version, so there is no
way to pull a second copy into the bundle. The real libraries enter only as
devDependencies, because the semantics worth testing are the ones a fake would
paper over.

if (import.meta.env.DEV) {
  registerStores(zustandAdapter(useCartStore, { id: "cart" }));
}

A reachable page API — window.__REACT_LENS__ is now installed by the
extension's MAIN-world bridge at document_start and by the embedded runtime at
construction (it used to be installed in start(), dropping module-level
calls). installPageApi adopts registrations queued before a host arrives, so
import order does not matter, and a page navigation's re-install does not
double-register. The bridge also gains markInteraction, previously
embedded-only.

Per-store restore results — storesApplied / storeFailures[{ storeId, reason }] split out from the component counters, so a store that has no
snapshot that far back is named instead of inflating the component count.
Baselines are now taken before component writes land and restored after
components on go-live (a component restore can push to a store through an
effect); reference-identical snapshots no longer consume retention; adapter
errors route to the existing emitAgentError seam instead of a bare catch.

The site's claims, corrected. apps/site told visitors that "server state"
and "most external stores" stay put — true before this branch, wrong after it.
"What travels" now names registered stores, and the live specimen gains a store
counter next to the uncontrolled input that deliberately does not rewind, so the
claim is demonstrated rather than asserted. It uses createStoreAdapter over a
module singleton: same one-line registration as the Zustand case, without
shipping a store library to a marketing page.

The restore indicator, rebuilt. It read 199 restored · 3 stores · 1 unavailable — three unrelated facts in one flat amber run, with the loudest
position given to the least useful number and the only actionable detail hidden
in a tooltip. It is now an icon chip: a store glyph and the count that followed
the playhead, or an amber alert glyph and the count that did not, with the
sentence in the tooltip and accessible name and the explanation one click away.
The popover is portaled to .rl-root with fixed positioning — in place it was
clipped by .rl-cascade-toolbar { overflow: hidden } and painted under the
graph canvas.

Incidental fix: the Doctor worker was dead in every embedded panel

CI caught this on the branch, and it turned out not to be a flaky test.

embed.tsx wraps the panel in StrictMode, so effects mount, clean up and
mount again. The Doctor client was created in a useMemo but terminated by
the effect's cleanup
, so the second mount reused an already-terminated
worker: ingests went nowhere, no result came back, and the toolbar badge sat at
"No Doctor issues" permanently — as did the tree's ⚕ badges. The inspector's
Doctor strip kept working because it runs diagnoseOne on the panel's own
store, which is why only one of four doctor specs failed while the feature was
dead for every embedded user.

Measured against the fixture's live store, diagnoseAll returns 50 diagnostics
over 26 components while the badge showed 0. It now reads 50.

  • The client is created and disposed by the same effect, so its lifetime matches
    React's, and dispose() leaves it inert.
  • createDoctorClient takes an injectable spawn (testable) and an onError,
    because new Worker resolves its script asynchronously — a construction that
    succeeds is no promise the worker runs. A worker that dies now reports through
    the panel's error seam instead of reading as a healthy app with nothing to say.
  • The synchronous fallback is derived from "no worker result yet" rather than "no
    client", so it also covers a worker that loaded and then died.

Trade-offs worth reviewing

  • TanStack Query clears the cache before hydrating by default. The specs
    turned up that hydrate keeps whichever copy is newer, so a plain merge
    cannot rewind a query the app has since refetched — it can only backfill.
    Merge stays available as mode: "merge", with the limitation stated in the
    type. In the default mode, observers on cleared keys refetch.
  • Redux needs the app's cooperation: state only moves through a reducer, so
    withTimeTravel(rootReducer) is required. The adapter throws a named error on
    the first restore if it is missing, rather than leaving the playhead silently
    doing nothing.
  • TimeTravelResult gained two required fields. Additive, but every
    construction site (instrumentation, transport, panel, controller) had to fill
    them in.

Commits

Atomic, red before green:

  1. refactor(protocol) — own the store adapter and page API contracts
  2. test(instrumentation) / 3. feat(instrumentation) — per-store restore results
  3. test(adapters) / 5. feat(adapters) — the package, specified against real libraries
  4. feat(extension) — expose the page store-registration API
  5. test(devtools) / 8. feat(devtools) — store failures in the panel status
  6. test(e2e) — rewind a Zustand store through the page API
  7. docs — docs/stores.md, DESIGN, INTERFACES, ROADMAP, README
  8. feat(playground) — one demo card per shipped adapter
  9. test(devtools) / 13. feat(devtools) — rebuild the restore indicator
  10. test(devtools) / 15. fix(devtools) — keep the Doctor worker alive under StrictMode
  11. docs(site) — claim and demonstrate external-store rewind

Verification

  • pnpm check clean, pnpm typecheck clean, pnpm build green including the
    new package.
  • 771 unit tests pass. Adapter specs run against real zustand, redux and
    @tanstack/query-core.
  • 52/52 e2e specs pass — the full embed suite is green, including the
    doctor.spec case that was red before this branch. The fixture's cart is now
    a real Zustand store registered through the shipped adapter, so the existing
    rewind spec exercises the whole new stack.
  • Manually verified in the playground: driving all four demo stores then walking
    back through interactions rewinds each one (Query batch 1003 → 1002 → 1001,
    grind coarse → medium, Redux Tamp dropped before Grind), and "Latest"
    returns every value to live. Registering a store mid-session and scrubbing
    before its first snapshot turns the chip amber with the popover naming
    latecomer — no snapshot this far back.

Known gaps

  • The MV3 extension e2e project is still a skipped placeholder harness, so
    the bridge's own install site has no e2e of its own. It shares
    installPageApi with the embedded runtime, which is unit-tested, and I
    verified the global in a live page.
  • No first-class Jotai adapter: Jotai has no public API to enumerate a store's
    atoms, so it is covered by createStoreAdapter plus a documented recipe.

🤖 Generated with Claude Code

Fausto95 and others added 13 commits August 17, 2026 09:54
Time travel's external-store seam lived in @reactlens/instrumentation,
which the page cannot import, and the page-facing global was an ad-hoc
shape declared inline in captureRuntime. Both are shared contracts, so
they belong in the pure-types package that every layer already depends
on.

- TimeTravelStoreAdapter moves to protocol; instrumentation re-exports
  it, so existing imports keep working.
- page-api.ts declares __REACT_LENS__ plus install/resolve helpers with
  a pending queue, so a registration made before the host installs is
  adopted rather than lost.
- TimeTravelResult gains storesApplied/storeFailures and the transport
  message shapes are expressed as `& TimeTravelResult` instead of
  restating each field. Values are still filled with zeros here; the
  behaviour lands in the next commits.
- snapshotsPerStore joins TIME_TRAVEL_RETENTION, the single retention
  source of truth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red. Store restores currently fold into the component applied/failed
counters with no id and no reason, so the panel can report "1
unavailable" with nothing to name. These specs pin the behaviour the
next commit implements:

- storesApplied/storeFailures are separate from component counts
- failures carry the store id and a reason (no-snapshot, apply-failed)
- an unchanged snapshot does not consume retention
- adapter errors reach onError instead of a bare catch
- the go-live baseline is taken before component writes land
- a stale unregister cannot tear down a same-id re-registration

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Green. Store restores are now first-class rather than an anonymous
addition to the component counters.

- applied/failed count components only; storesApplied/storeFailures
  carry the store side, each failure naming the store and why
  (no-snapshot, snapshot-failed, apply-failed).
- Go-live baselines are taken before any component write lands, and
  stores are restored after components in goLive: a component restore
  can push to a store through an effect or a subscription, and the
  live baseline must be what the page ends up with.
- Snapshots identical to the previous one no longer consume retention,
  so commits that leave a store alone cannot evict real history —
  meaningful for an expensive getSnapshot such as Query's dehydrate.
- Adapter errors route to instrumentation's emitAgentError instead of a
  bare catch, so a broken adapter is visible in the panel.
- Unregister is identity-checked, so a hot-reloaded module's stale
  cleanup cannot tear down the re-registration that replaced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red — the package does not exist yet. Specs run against the real
libraries rather than fakes, because the semantics worth pinning are
exactly the ones a fake would paper over: zustand's replace flag
dropping keys added after the snapshot, redux refusing to restore
without a wrapped reducer, and TanStack Query's hydrate merging instead
of replacing.

Registration is specified around the ordering every real app hits:
modules evaluate before the host installs, so a registration made in
between must be adopted, an unregister in between must be honoured, and
neither may throw when React Lens is not running at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@reactlens/adapters turns the existing registration seam into something
an app can actually use: registerStore/registerStores resolve the page
API (queueing if no host has installed yet), and createStoreAdapter is
the single code path every shipped adapter wraps.

Zustand restores with replace:true so keys added after the snapshot are
dropped; actions ride along in the state object and stay callable.
Redux ships withTimeTravel for the root reducer — state can only move
through a reducer — and the adapter throws a named error when the
reducer was not wrapped, since a playhead that silently does nothing is
worse than a loud failure.

TanStack Query clears before hydrating by default. The specs found that
hydrate keeps whichever copy is newer, so a plain merge leaves a query
the app has since updated at its live value: merge cannot rewind, only
backfill. It stays available as mode: "merge" for a non-destructive
scrub, with the limitation stated in the type.

No dependency or peer dependency on any of the three libraries — stores
are matched structurally and Query's dehydrate/hydrate are passed in
from the app's own version, so there is no way to pull a second copy
into the bundle. They enter only as devDependencies, because the
semantics worth testing are the ones a fake would paper over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Store adapters were only reachable through the embedded runtime object,
which the playground and the e2e fixture construct — an app debugged
through the Chrome extension had no way to register anything at all,
which is the path almost every real user is on.

The MAIN-world bridge now installs __REACT_LENS__ at module scope, so
it is there at document_start before the app's modules evaluate, and it
carries markInteraction too — previously embedded-only. The embedded
runtime installs the same surface at construction rather than in
start(), where module-level calls made during boot were being dropped.

Both go through installPageApi, so a registration made before the host
arrives is adopted rather than lost, and a page navigation's re-install
does not double-register what the previous host already drained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red. The controller drops storesApplied/storeFailures on the floor, so
the panel cannot say which store failed to rewind. Store state is
whole-app rather than per-delta, so unlike component failures the
newest apply replaces the previous verdict instead of accumulating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Green. The controller carries storesApplied/storeFailures into
RestoreStatus, and the pill reads "12 restored · 2 stores · 1
unavailable" with the failing store named in the tooltip ("cart — no
snapshot this far back").

The pill itself had no render site: the panel discarded the restore
status entirely, so none of this feedback — component failures
included — ever reached the screen. It now sits next to the rewind
toggle in the transport slot, which is also what makes the store side
observable at all.

Clicking still jumps to the first failed component; a store-only
failure leaves the pill inert, since there is no component to select.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fixture's cart was a hand-rolled store registered through the
embedded runtime object — a path no extension user can take. It is now
a real Zustand store registered with registerStores(zustandAdapter(...)),
so the existing rewind spec exercises the shipped adapter and the
__REACT_LENS__ surface end to end instead of a bespoke stand-in.

Two specs added: one asserting the page API is what the app registered
through, one covering the restore pill's store count and its
disappearance on go-live.

The MV3 extension project is still a skipped placeholder harness, so
the bridge's own install site has no e2e of its own; it shares
installPageApi with the embedded runtime, which the protocol unit tests
cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/stores.md is the guide: per-library setup, why Redux needs its
reducer wrapped, why the Query adapter clears before hydrating, and the
createStoreAdapter recipe for Jotai or a module singleton. Retention,
immutability and failure behaviour are stated rather than implied.

README drops "unregistered stores" from the asterisk and points at it;
DESIGN and INTERFACES gain the page API, the per-store result fields
and the baseline ordering; the panel guide describes the restore pill,
which until now was undocumented because nothing rendered it.

The playground grows a RoastFilter scenario — a Zustand store outside
React, registered through @reactlens/adapters, whose chips follow the
playhead. It replaces the stale ExternalStoreDemo reference in DESIGN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only Zustand had a demo, so two thirds of what @reactlens/adapters
ships was undemonstrated. scenarios/stores/ now holds one card per
adapter, grouped under a single section rather than scattered as loose
cards:

- RoastFilter — Zustand, replacing setState
- ReduxTasks — Redux Toolkit with the root reducer wrapped
- QueryStock — TanStack Query over a fake fetch, so the refetch/rewind
  trade-off is visible: scrub back and the old batch number returns
- SingletonTheme — createStoreAdapter over a plain module singleton,
  which is also the Jotai recipe

Each registers behind import.meta.env.DEV, so the production bundle
carries no registration and, for Redux, the unwrapped reducer.

Verified in the playground: driving all four then walking back through
interactions rewinds each one — batch 1003 → 1002 → 1001, coarse →
medium, Tamp dropped before Grind — with the pill reading "199 restored
· 4 stores", and Latest returning every value to live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red — the component does not exist yet. The current pill reads "199
restored · 3 stores · 1 unavailable": three unrelated facts in one flat
amber run, where the loudest number is the least useful one and the
only actionable detail (which store, and why) is hidden in a tooltip.

These specs pin the replacement: quiet and uninteractive while
everything follows the playhead, and when something does not, a chip
that names the failing kind and opens a popover explaining each failure
in plain language — stores first, component rows clicking through to
the component, nothing truncated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"199 restored · 3 stores · 1 unavailable" put three unrelated facts in
one flat amber run, gave the loudest position to the least useful
number, and hid the only actionable detail — which store, and why — in
a tooltip.

The chip is now icon-led and numeric, with room to breathe: a store
glyph and the count of stores that followed the playhead, or an alert
glyph and the count that did not. The sentence moved to the tooltip and
the accessible name, so nothing is only-visual. IconStore is new
because the rewind glyph belongs to the travel toggle this chip sits
beside, and two identical glyphs read as two toggles.

The popover is portaled to the panel root with fixed positioning taken
from the chip's rect. In place it was unusable: .rl-cascade-toolbar
clips its overflow and the graph canvas painted over it, so the list
was both cut off and buried. It portals to .rl-root rather than body to
keep the panel's theme variables, and the fixed-position rule needs two
classes to beat the generic .rl-menu block that follows it.

Verified in the playground: healthy reads as a store glyph and 4;
registering a store mid-session and scrubbing before its first snapshot
turns the chip amber, and the popover opens above the canvas, aligned
to the chip, naming "latecomer — no snapshot this far back".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-lens-devtools Ready Ready Preview Aug 17, 2026 9:28am

Fausto95 and others added 2 commits August 17, 2026 11:11
Red. CI fails on doctor.spec's "Doctor menu lists evidence and a next
step": the toolbar badge stays disabled at "No Doctor issues" while the
inspector's Doctor strip shows findings for the same component.

Measured against the fixture's live store, diagnoseAll returns 50
diagnostics over 26 components — the rules and the data are fine, and
the badge reads the worker's count. Two defects keep it at zero, and
these specs pin both: a client whose worker dies after construction
reports nothing forever with no error path, and dispose() must leave the
client inert rather than half-alive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Green — doctor.spec passes, and with it the whole embed suite (52/52).

The embedded panel mounts under StrictMode (embed.tsx), so every effect
mounts, cleans up and mounts again. The Doctor client was created in a
useMemo but terminated by the effect's cleanup, so the second mount
reused a worker that had already been terminated: ingests went nowhere,
no result ever came back, and the toolbar badge sat at "No Doctor
issues" forever. The inspector's Doctor strip kept working because it
runs diagnoseOne on the panel's own store — which is why only one spec
failed while the feature was dead for every embedded user.

Measured on the fixture's live store, diagnoseAll returns 50
diagnostics over 26 components; the badge now reads 50.

- The client is created and disposed by the same effect, so its
  lifetime matches React's, and dispose() leaves it inert.
- createDoctorClient takes an injectable spawn (testable) and an
  onError, because `new Worker` resolves its script asynchronously: a
  construction that succeeds is no promise the worker runs. A worker
  that dies now reports through the panel's error seam instead of
  reading as a healthy app with nothing to say.
- The synchronous fallback is derived from "no worker result yet"
  rather than "no client", so it also covers a worker that loaded and
  then died, and stops as soon as a real result lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The site still told visitors that "server state" and "most external
stores" stay put when the playhead moves — true before this branch,
wrong after it, and the one page most likely to be read as the product's
promise.

"What travels" now names registered stores; "What doesn't" keeps refs,
uncontrolled inputs, imperative DOM writes and unregistered module
state, and says that an external store rewinds once you opt it in.

The live specimen gains a "store" counter so the claim is demonstrated
rather than asserted, sitting next to the uncontrolled input that
deliberately does not rewind. It uses createStoreAdapter over a module
singleton: the registration is the same one line as the Zustand case,
without shipping a store library to a marketing page.

Verified on the running site: the store reads 2, and stepping back
through interactions moves it to 0 and 1 with the restore chip showing
one store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Fausto95
Fausto95 merged commit 5101603 into main Aug 17, 2026
4 checks passed
@Fausto95
Fausto95 deleted the feat/time-travel-store-adapters branch August 17, 2026 09:33

This branch was successfully deployed

1 active deployment
Preview — 75a5de3f Deployed Aug 17, 2026 by vercel[bot]
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