edmundhung / conform

A type-safe form validation library utilizing web fundamentals to progressively enhance HTML Forms with full support for server frameworks like Remix and Next.js.
https://conform.guide
MIT License
1.8k stars 101 forks source link

useInputControl breaks undo when onPaste is set #744

Closed punkpeye closed 3 weeks ago

punkpeye commented 3 weeks ago

Describe the bug and the expected behavior

I would expect that I can undo after pasting.

Conform version

v1.1.5

Steps to Reproduce the Bug or Issue

The necessary code is just:

const messageInputControl = useInputControl(fields.message);

<textarea
  autoFocus
  name={fields.message.name}
  onChange={(event) => {
    messageInputControl.change(event.target.value);
  }}
  onPaste={(event) => {
    messageInputControl.change(
      messageInputControl.value + 'test',
    );
    event.preventDefault();
  }}
  value={messageInputControl.value}
/>

What browsers are you seeing the problem on?

Chrome

Screenshots or Videos

No response

Additional context

It seems that when onPaste is added, undo (cmd + z) starts doing something weird that's not undoing the last paste.

punkpeye commented 3 weeks ago

@edmundhung Happy to create a repro if you have a sandbox I could use. However, the issue is fairly isolated.

punkpeye commented 3 weeks ago

ah, after familiarizing more with the problem, I realized this is just a general issue with controlled components.

I can write an abstraction that handles this, but shouldn't this be handled by useInputControl ?

punkpeye commented 3 weeks ago
import { type Reducer, useCallback, useReducer, useRef } from 'react';

type HistoryState<T> = {
  canRedo: boolean;
  canUndo: boolean;
  clear: () => void;
  redo: () => void;
  set: (newPresent: T) => void;
  state: T;
  undo: () => void;
};

type ReducerAction<T> =
  | {
      initialPresent: T;
      type: 'CLEAR';
    }
  | {
      newPresent: T;
      type: 'SET';
    }
  | {
      type: 'UNDO';
    }
  | {
      type: 'REDO';
    };

const initialUseHistoryStateState = {
  future: [],
  past: [],
  present: null,
};

type ReducerState<T> = {
  future: T[];
  past: T[];
  present: T;
};

const stateReducer = <T>(
  state: ReducerState<T>,
  action: ReducerAction<T>,
): ReducerState<T> => {
  const { past, present, future } = state;

  if (action.type === 'UNDO') {
    return {
      future: [present, ...future],
      past: past.slice(0, past.length - 1),
      present: past[past.length - 1],
    };
  } else if (action.type === 'REDO') {
    return {
      future: future.slice(1),
      past: [...past, present],
      present: future[0],
    };
  } else if (action.type === 'SET') {
    const { newPresent } = action;

    if (action.newPresent === present) {
      return state;
    }

    return {
      future: [],
      past: [...past, present],
      present: newPresent,
    };
  } else if (action.type === 'CLEAR') {
    return {
      ...initialUseHistoryStateState,
      present: action.initialPresent,
    };
  } else {
    throw new Error('Unsupported action type');
  }
};

export const useHistoryState = <T>(initialPresent: T): HistoryState<T> => {
  const initialPresentRef = useRef(initialPresent);

  const [state, dispatch] = useReducer<
    Reducer<ReducerState<T>, ReducerAction<T>>
  >(stateReducer, {
    ...initialUseHistoryStateState,
    present: initialPresentRef.current,
  });

  const canUndo = state.past.length !== 0;
  const canRedo = state.future.length !== 0;

  const undo = useCallback(() => {
    if (canUndo) {
      dispatch({ type: 'UNDO' });
    }
  }, [canUndo]);

  const redo = useCallback(() => {
    if (canRedo) {
      dispatch({ type: 'REDO' });
    }
  }, [canRedo]);

  const set = useCallback<(newPresent: T) => void>(
    (newPresent) => dispatch({ newPresent, type: 'SET' }),
    [],
  );

  const clear = useCallback(
    () =>
      dispatch({ initialPresent: initialPresentRef.current, type: 'CLEAR' }),
    [],
  );

  return { canRedo, canUndo, clear, redo, set, state: state.present, undo };
};

Ripped out of https://github.com/uidotdev/usehooks