Skip to content

Commit 2c29408

Browse files
refactor(bippy): improve time-travel robustness and add features
Improvements: - Proper hook type identification (State, Reducer, Ref, Effect, Memo, etc.) - Track which hooks are actually editable (only State and Reducer) - Use WeakRef for fiber references to handle double-buffering - Add class component state tracking support - Add external state manager integration hooks (captureExternalState, restoreExternalState) - Add onBeforeRestore callback for restore cancellation - Add fibersByName index for faster component lookups - Better error handling during restoration - Skip non-serializable props (functions, key, ref, children) - Use getLatestFiber to handle fiber alternates correctly - Add utility methods: getTrackedFiberIds, getTrackedComponentNames, getEditableStateCount - Include hookType in diff results and state timeline - Export/import now includes version and external state Test improvements: - Add hook type identification tests - Add external state capture/restore tests - Add onBeforeRestore callback tests - Add utility method tests - Fix test cleanup between runs Co-authored-by: aiden <aiden@million.dev>
1 parent 82466f5 commit 2c29408

2 files changed

Lines changed: 595 additions & 69 deletions

File tree

packages/bippy/src/test/time-travel.test.tsx

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,7 @@ describe('TimeTravel', () => {
609609
const lastSnapshot = history[history.length - 1];
610610
for (const fiberSnapshot of lastSnapshot.fibers.values()) {
611611
if (fiberSnapshot.displayName === 'MultiStateComponent') {
612-
expect(fiberSnapshot.hookStates.length).toBeGreaterThan(0);
612+
expect(fiberSnapshot.hooks.length).toBeGreaterThan(0);
613613
}
614614
}
615615
});
@@ -652,5 +652,212 @@ describe('TimeTravel', () => {
652652
const history = timeTravel.getHistory();
653653
expect(history.length).toBeGreaterThan(0);
654654
});
655+
656+
it('should identify hook types correctly', async () => {
657+
const timeTravel = createTimeTravel({
658+
trackComponents: ['MultiStateComponent'],
659+
dangerouslyRunInProduction: true,
660+
});
661+
662+
render(<MultiStateComponent />);
663+
664+
await act(async () => {
665+
await new Promise((resolve) => setTimeout(resolve, 10));
666+
});
667+
668+
const history = timeTravel.getHistory();
669+
expect(history.length).toBeGreaterThan(0);
670+
671+
const lastSnapshot = history[history.length - 1];
672+
for (const fiberSnapshot of lastSnapshot.fibers.values()) {
673+
if (fiberSnapshot.displayName === 'MultiStateComponent') {
674+
const stateHooks = fiberSnapshot.hooks.filter((hook) => hook.hookType === 'State');
675+
expect(stateHooks.length).toBeGreaterThan(0);
676+
expect(stateHooks.every((hook) => hook.isEditable)).toBe(true);
677+
}
678+
}
679+
});
680+
});
681+
682+
describe('external state support', () => {
683+
it('should capture external state if provided', async () => {
684+
let externalCounter = 0;
685+
const captureExternalState = vi.fn(() => [
686+
{ key: 'counter', value: externalCounter },
687+
]);
688+
689+
const timeTravel = createTimeTravel({
690+
dangerouslyRunInProduction: true,
691+
captureExternalState,
692+
});
693+
694+
render(<Counter />);
695+
696+
await act(async () => {
697+
await new Promise((resolve) => setTimeout(resolve, 10));
698+
});
699+
700+
expect(captureExternalState).toHaveBeenCalled();
701+
});
702+
703+
it('should restore external state if provided', async () => {
704+
let externalCounter = 0;
705+
const restoreExternalState = vi.fn((entries) => {
706+
const counterEntry = entries.find((entry: { key: string }) => entry.key === 'counter');
707+
if (counterEntry) {
708+
externalCounter = counterEntry.value;
709+
}
710+
});
711+
712+
const timeTravel = createTimeTravel({
713+
dangerouslyRunInProduction: true,
714+
captureExternalState: () => [{ key: 'counter', value: externalCounter }],
715+
restoreExternalState,
716+
});
717+
718+
render(<Counter />);
719+
720+
await act(async () => {
721+
await new Promise((resolve) => setTimeout(resolve, 10));
722+
});
723+
724+
externalCounter = 5;
725+
726+
const incrementButton = screen.getByTestId('increment');
727+
728+
await act(async () => {
729+
fireEvent.click(incrementButton);
730+
await new Promise((resolve) => setTimeout(resolve, 10));
731+
});
732+
733+
if (timeTravel.canGoBack()) {
734+
timeTravel.goBack();
735+
expect(restoreExternalState).toHaveBeenCalled();
736+
}
737+
});
738+
});
739+
740+
describe('onBeforeRestore callback', () => {
741+
it('should call onBeforeRestore before restoring', async () => {
742+
const onBeforeRestore = vi.fn(() => true);
743+
const timeTravel = createTimeTravel({
744+
onBeforeRestore,
745+
dangerouslyRunInProduction: true,
746+
});
747+
748+
render(<Counter />);
749+
750+
await act(async () => {
751+
await new Promise((resolve) => setTimeout(resolve, 10));
752+
});
753+
754+
const incrementButton = screen.getByTestId('increment');
755+
756+
await act(async () => {
757+
fireEvent.click(incrementButton);
758+
await new Promise((resolve) => setTimeout(resolve, 10));
759+
});
760+
761+
if (timeTravel.canGoBack()) {
762+
timeTravel.goBack();
763+
expect(onBeforeRestore).toHaveBeenCalled();
764+
}
765+
});
766+
767+
it('should prevent restore if onBeforeRestore returns false', async () => {
768+
const onBeforeRestore = vi.fn(() => false);
769+
const onRestore = vi.fn();
770+
const timeTravel = createTimeTravel({
771+
onBeforeRestore,
772+
onRestore,
773+
dangerouslyRunInProduction: true,
774+
});
775+
776+
render(<Counter />);
777+
778+
await act(async () => {
779+
await new Promise((resolve) => setTimeout(resolve, 10));
780+
});
781+
782+
const incrementButton = screen.getByTestId('increment');
783+
784+
await act(async () => {
785+
fireEvent.click(incrementButton);
786+
await new Promise((resolve) => setTimeout(resolve, 10));
787+
});
788+
789+
const indexBeforeGoBack = timeTravel.getCurrentIndex();
790+
791+
if (timeTravel.canGoBack()) {
792+
timeTravel.goBack();
793+
expect(onBeforeRestore).toHaveBeenCalled();
794+
expect(onRestore).not.toHaveBeenCalled();
795+
expect(timeTravel.getCurrentIndex()).toBe(indexBeforeGoBack);
796+
}
797+
});
798+
});
799+
800+
describe('utility methods', () => {
801+
it('should return tracked fiber IDs', async () => {
802+
const timeTravel = createTimeTravel({
803+
dangerouslyRunInProduction: true,
804+
});
805+
806+
render(<Counter />);
807+
808+
await act(async () => {
809+
await new Promise((resolve) => setTimeout(resolve, 10));
810+
});
811+
812+
const fiberIds = timeTravel.getTrackedFiberIds();
813+
expect(Array.isArray(fiberIds)).toBe(true);
814+
});
815+
816+
it('should return tracked component names', async () => {
817+
const timeTravel = createTimeTravel({
818+
dangerouslyRunInProduction: true,
819+
});
820+
821+
render(<Counter />);
822+
823+
await act(async () => {
824+
await new Promise((resolve) => setTimeout(resolve, 10));
825+
});
826+
827+
const componentNames = timeTravel.getTrackedComponentNames();
828+
expect(Array.isArray(componentNames)).toBe(true);
829+
});
830+
831+
it('should return editable state count', async () => {
832+
const timeTravel = createTimeTravel({
833+
dangerouslyRunInProduction: true,
834+
});
835+
836+
render(<Counter />);
837+
838+
await act(async () => {
839+
await new Promise((resolve) => setTimeout(resolve, 10));
840+
});
841+
842+
const fiberIds = timeTravel.getTrackedFiberIds();
843+
if (fiberIds.length > 0) {
844+
const count = timeTravel.getEditableStateCount(fiberIds[0]);
845+
expect(typeof count).toBe('number');
846+
}
847+
});
848+
849+
it('should report restore in progress state', async () => {
850+
const timeTravel = createTimeTravel({
851+
dangerouslyRunInProduction: true,
852+
});
853+
854+
render(<Counter />);
855+
856+
await act(async () => {
857+
await new Promise((resolve) => setTimeout(resolve, 10));
858+
});
859+
860+
expect(timeTravel.isRestoreInProgress()).toBe(false);
861+
});
655862
});
656863
});

0 commit comments

Comments
 (0)