Repository navigation
Widen time travel via store adapters - #19
Merged
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 wasonly 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, andcreateStoreAdapterforanything else (Jotai, module singletons). Zero dependency and zero peer
dependency on any store library: stores are matched structurally, and Query's
dehydrate/hydrateare passed in from the app's own version, so there is noway 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.
A reachable page API —
window.__REACT_LENS__is now installed by theextension's MAIN-world bridge at
document_startand by the embedded runtime atconstruction (it used to be installed in
start(), dropping module-levelcalls).
installPageApiadopts registrations queued before a host arrives, soimport order does not matter, and a page navigation's re-install does not
double-register. The bridge also gains
markInteraction, previouslyembedded-only.
Per-store restore results —
storesApplied/storeFailures[{ storeId, reason }]split out from the component counters, so a store that has nosnapshot 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
emitAgentErrorseam instead of a barecatch.The site's claims, corrected.
apps/sitetold 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
createStoreAdapterover amodule 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 loudestposition 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-rootwith fixed positioning — in place it wasclipped by
.rl-cascade-toolbar { overflow: hidden }and painted under thegraph 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.tsxwraps the panel inStrictMode, so effects mount, clean up andmount again. The Doctor client was created in a
useMemobut terminated bythe 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
diagnoseOneon the panel's ownstore, 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,
diagnoseAllreturns 50 diagnosticsover 26 components while the badge showed 0. It now reads 50.
React's, and
dispose()leaves it inert.createDoctorClienttakes an injectablespawn(testable) and anonError,because
new Workerresolves its script asynchronously — a construction thatsucceeds 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.
client", so it also covers a worker that loaded and then died.
Trade-offs worth reviewing
turned up that
hydratekeeps whichever copy is newer, so a plain mergecannot rewind a query the app has since refetched — it can only backfill.
Merge stays available as
mode: "merge", with the limitation stated in thetype. In the default mode, observers on cleared keys refetch.
withTimeTravel(rootReducer)is required. The adapter throws a named error onthe first restore if it is missing, rather than leaving the playhead silently
doing nothing.
TimeTravelResultgained two required fields. Additive, but everyconstruction site (instrumentation, transport, panel, controller) had to fill
them in.
Commits
Atomic, red before green:
refactor(protocol)— own the store adapter and page API contractstest(instrumentation)/ 3.feat(instrumentation)— per-store restore resultstest(adapters)/ 5.feat(adapters)— the package, specified against real librariesfeat(extension)— expose the page store-registration APItest(devtools)/ 8.feat(devtools)— store failures in the panel statustest(e2e)— rewind a Zustand store through the page APIdocs—docs/stores.md, DESIGN, INTERFACES, ROADMAP, READMEfeat(playground)— one demo card per shipped adaptertest(devtools)/ 13.feat(devtools)— rebuild the restore indicatortest(devtools)/ 15.fix(devtools)— keep the Doctor worker alive under StrictModedocs(site)— claim and demonstrate external-store rewindVerification
pnpm checkclean,pnpm typecheckclean,pnpm buildgreen including thenew package.
zustand,reduxand@tanstack/query-core.doctor.speccase that was red before this branch. The fixture's cart is nowa real Zustand store registered through the shipped adapter, so the existing
rewind spec exercises the whole new stack.
back through interactions rewinds each one (Query batch 1003 → 1002 → 1001,
grind coarse → medium, Redux
Tampdropped beforeGrind), 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 bridge's own install site has no e2e of its own. It shares
installPageApiwith the embedded runtime, which is unit-tested, and Iverified the global in a live page.
atoms, so it is covered by
createStoreAdapterplus a documented recipe.🤖 Generated with Claude Code