molefrog / wouter

🥢 A minimalist-friendly ~2.1KB routing for React and Preact
https://npm.im/wouter
The Unlicense
6.74k stars 155 forks source link

Using `useSearch` with SSR throws a hydration warning #446

Open molefrog opened 6 months ago

molefrog commented 6 months ago

How to reproduce:

  1. Create a web-server and render wouter app with <Router ssrPath={requestUrl.pathname} ssrSearch={requestUrl.search}>
  2. On the client-side render your app wrapped in just <Router />
  3. You will see the hydration warning.

Expected: when wouter runs in the browser, by default it should fill in ssrPath and ssrSearch with current pathname and search string extracted from location

antoniopresto commented 1 month ago

Looks like this is the reason 🤔

It’s not recommended to suspend a render based on a store value returned by useSyncExternalStore. The reason is that mutations to the external store cannot be marked as non-blocking Transition updates, so they will trigger the nearest Suspense fallback, replacing already-rendered content on screen with a loading spinner, which typically makes a poor UX. https://react.dev/reference/react/useSyncExternalStore#caveats

...replacing already-rendered content on screen with a loading spinner is exactly what is happening here

antoniopresto commented 1 month ago

Not sure if you are accepting new code, based on the last open PRs

molefrog commented 1 month ago

Not sure if you are accepting new code, based on the last open PRs

We are accepting new code! The current open PRs are quite difficult to merge, since there were intended for v2, but I stopped adding new features when I was working on v3 release. I think these PRs have to be closed unfortunately and reimplemented from scratch.

Your contributions are always welcome!

antoniopresto commented 1 month ago

Oh cool! I'll work on a fix this weekend :D Thanks @molefrog!

antoniopresto commented 1 month ago

Hi! I created this as a "hotfix" locally - just works™️

import { IS_BROWSER } from 'powership';

function parseLocationEntries() {
  return [
    ['path', window.location.pathname],
    ['search', window.location.search],
  ] as const;
}

export type LocationEntry = ReturnType<typeof parseLocationEntries>;

export type RouteChangeCallback = (changes: LocationEntry) => void;

const subscribers = new Set<RouteChangeCallback>();

if (IS_BROWSER) {
  let current = parseLocationEntries();
  const callback = () => {
    if (!subscribers.size) return;
    const next = parseLocationEntries();
    const changed = next.filter((el, idx) => el[1] !== current[idx][1]);
    if (!changed.length) return;
    current = next;
    subscribers.forEach((fn) => {
      fn(current);
    });
  };

  const events = ['popstate', 'pushState', 'replaceState', 'hashchange'];

  for (const event of events) {
    addEventListener(event, callback);
  }
}

export function observeRouteChange(cb: RouteChangeCallback) {
  subscribers.add(cb);
  return () => {
    subscribers.delete(cb);
  };
}
// example
function useRoute() {
  const router = useWouter();
  const [route, setRoute] = React.useState(() => {
    return {
      search: router.ssrSearch || '',
      path: router.ssrPath || '',
    };
  });

  useEffect(() => {
    return observeRouteChange((changes) => {
      setRoute((state) => {
        changes.forEach(([k, v]) => {
          state = { ...state, [k]: v };
        });
        return state;
      });
    });
  }, []);

  return route;
}

edit: removed startTransition