Astro adapter — SSR-seeded search params to fix #418 hydration mismatch in islands #1425
Replies: 2 comments
|
I'd love a PR, unfortunately they are currently paused for security reasons (see #1401), but I'm hoping to reopen them in the coming weeks once I've setup the proper defences in our release pipeline. I thought Astro needed a dependency on nanostores to have cross-island sync capabilities? We had some experiments for this in #1007, but adding the dependency for all frameworks seemed a bit overkill (I'd be OK with it if we could scope it to the Astro adapter somehow). I haven't played with SSR in Astro so I'm not sure how to best address your design questions. Relevant issues & discussions: |
|
No rush on the PR — happy to wait until #1401 is sorted and open it as a draft then. In the meantime I've put the current adapter at the bottom of this comment in case it's useful to anyone landing here from #792. On nanostores: I don't think the Astro adapter needs it at all. The trick is that the cross-instance emitter in your react adapter just needs to be module-scoped rather than living in React context. Astro islands on the same page share one client module graph, so a module-level emitter (plus module-level One caveat for honesty: our production app runs a single island, so multi-island sync is correct by construction (module-scoped emitter) but I haven't stress-tested it. The only real constraint is a single module instance of nuqs in the bundle — duplicated copies (e.g. pnpm peer-dep hashing across workspace packages) split the emitter. We hit that at the app level and fixed it with Vite Having sat with the design questions a bit more, I can answer my own:
The SSR-seeding part is the actual delta over the react adapter: on the server, seed initial state from Current adapter (running in production)"use client";
import {
createContext,
createElement,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import {
renderQueryString,
unstable_createAdapterProvider as createAdapterProvider,
type unstable_UpdateUrlFunction as UpdateUrlFunction,
} from "nuqs/adapters/custom";
// Request search string ("?o=SYD&d=SIN") on the server; "" on the client.
const ServerSearchContext = createContext("");
// --- key isolation, ported from nuqs' internal helpers ----------------------
// Each useQueryStates instance only "sees" the URL keys it manages, so an
// update to one set of keys doesn't clobber another instance's view.
function sameValues(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
function filterSearchParams(
search: URLSearchParams,
keys: string[],
copy: boolean,
): URLSearchParams {
if (keys.length === 0) return search;
const filtered = copy ? new URLSearchParams(search) : search;
for (const key of Array.from(filtered.keys())) {
if (!keys.includes(key)) filtered.delete(key);
}
return filtered;
}
function applyChange(
newValue: URLSearchParams,
keys: string[],
copy: boolean,
): (oldValue: URLSearchParams) => URLSearchParams {
return (oldValue) => {
const changed =
keys.length === 0 ||
keys.some(
(key) => !sameValues(oldValue.getAll(key), newValue.getAll(key)),
);
if (!changed) return oldValue;
return filterSearchParams(newValue, keys, copy);
};
}
// Seed initial params: from the request URL (serverSearch) during SSR, from
// location.search in the browser — both sides render identical params, which
// is the React #418 fix.
export function seedSearchParams(
watchKeys: string[],
serverSearch: string,
clientSearch: string | undefined,
): URLSearchParams {
return clientSearch === undefined
? filterSearchParams(new URLSearchParams(serverSearch), watchKeys, true)
: filterSearchParams(new URLSearchParams(clientSearch), watchKeys, false);
}
// --- cross-instance update emitter (mirrors nuqs' react adapter) ------------
// Module-scoped, so it also syncs across islands sharing the client bundle.
function createEmitter() {
const listeners = new Set<(search: URLSearchParams) => void>();
return {
on: (fn: (s: URLSearchParams) => void) => listeners.add(fn),
off: (fn: (s: URLSearchParams) => void) => listeners.delete(fn),
emit: (s: URLSearchParams) => listeners.forEach((fn) => fn(s)),
};
}
const emitter = createEmitter();
const updateUrl: UpdateUrlFunction = (search, options) => {
const url = new URL(location.href);
url.search = renderQueryString(search);
// Call directly rather than extracting the method — pulling
// history.pushState/replaceState off `history` loses its `this` binding.
if (options.history === "push") {
history.pushState(history.state, "", url);
} else {
history.replaceState(history.state, "", url);
}
// Same-document pushState/replaceState fires no popstate, so the other
// useQueryStates instances won't hear about this write unless we tell them.
emitter.emit(search);
if (options.scroll === true) window.scrollTo({ top: 0 });
};
function useSSRAwareAdapter(watchKeys: string[]) {
const serverSearch = useContext(ServerSearchContext);
const [searchParams, setSearchParams] = useState(() =>
seedSearchParams(
watchKeys,
serverSearch,
typeof location === "undefined" ? undefined : location.search,
),
);
useEffect(() => {
// Back/forward navigates the URL outside our emitter, so re-read location.
const onPopState = () =>
setSearchParams(
applyChange(new URLSearchParams(location.search), watchKeys, false),
);
// Another instance wrote the URL via updateUrl; adopt its keys we manage.
const onEmitterUpdate = (search: URLSearchParams) =>
setSearchParams(applyChange(search, watchKeys, true));
emitter.on(onEmitterUpdate);
window.addEventListener("popstate", onPopState);
return () => {
emitter.off(onEmitterUpdate);
window.removeEventListener("popstate", onPopState);
};
}, [watchKeys.join("&")]);
return { searchParams, updateUrl };
}
const SSRAwareAdapterProvider = createAdapterProvider(useSSRAwareAdapter);
export function NuqsSSRAdapter({
children,
serverSearch,
}: {
children: ReactNode;
serverSearch: string;
}) {
return createElement(
ServerSearchContext.Provider,
{ value: serverSearch },
createElement(SSRAwareAdapterProvider, null, children),
);
}Usage, per island: ---
// pages/index.astro
---
<SearchIsland client:load serverSearch={Astro.url.search} />// islands/SearchIsland.tsx
export default function SearchIsland({ serverSearch }: { serverSearch: string }) {
return (
<NuqsSSRAdapter serverSearch={serverSearch}>
{/* ...components using useQueryState(s) */}
</NuqsSSRAdapter>
);
} |
Uh oh!
There was an error while loading. Please reload this page.
nuqs works in Astro via the
customadapter, but there's no stock Astro adapter and thereactadapter can't be used directly: under Astro SSR there's nowindow, souseNuqsReactAdapterseeds from an emptyURLSearchParams(), renders the parser defaults, then mismatches the client on any deep-linked URL — React #418 hydration error plus a visible empty-state flash before the client corrects it.Unlike
next/app, an Astro adapter can't read the request server-side — islands are isolated, with no server hook intoAstro.url. So SSR seeding has to come from an explicit prop (serverSearch={Astro.url.search}) threaded into the island's adapter provider.I've been running an adapter that mirrors your
reactadapter (same cross-instance emitter + key-isolation helpers) plus aServerSearchContextthat seeds the initialuseStatefromserverSearchon the server andlocation.searchin the browser — so both sides render identical params and the deep-link mismatch goes away. It's a small, faithful diff on top of whatnuqs/adapters/reactalready does.Happy to open a draft PR for
nuqs/adapters/astroif you'd take it. Two design questions first:serverSearchprop the right shape, or is there a hook I'm missing to auto-seed from the request under Astro's islands model?.astrowrapper component, or keep it as the React provider users drop into their island root?All reactions