An unfamiliar crash was sitting at the top of Crashlytics during my usual morning check on the wallpaper apps.
TypeError: params.onFavorite is not a function
I had never seen it on my own device. I open that detail screen every day. Still, 19 occurrences the previous day — all Android, all clustered in the moments right after someone reopened the app.
A crash you cannot reproduce is a heavy thing to carry around. Once I traced this one, though, the answer turned out to be a yellow warning I had been politely ignoring for weeks.
The symptom — it only breaks on the way back
Laid out plainly:
| Situation | Result |
|---|---|
| List → detail navigation | Fine |
| Tapping the favorite button on detail | Fine |
| Background the app, return immediately | Fine |
| Background the app for 30+ minutes, then return | Crash |
| Development (Expo Go / dev build) | Warning only |
That fourth row is the whole story.
While the app sits in the background, the OS reclaims the process. To the user, nothing has happened — the app is still there in the task switcher. But the app died, and on resume the OS tries to put the user back on the screen they left.
What gets rebuilt at that moment is the saved navigation state.
Reproducing it — one developer option
You do not have to wait 30 minutes. Open the Android developer options and turn on:
- Don't keep activities
Now the process is destroyed the instant you press Home. Return to the app and the crash appears immediately. Mine reproduced on the very first try.
iOS behaves the same way in principle, but the timing depends on memory pressure, so reproducing it by hand takes patience. Android was the faster route.
The cause — navigation state is serialized to JSON
Here is what Rork generated, more or less untouched:
// The generated code, as I had been shipping it
const openDetail = (item: Wallpaper) => {
router.push({
pathname: "/wallpaper/detail",
params: {
item, // the whole object
publishedAt: item.publishedAt, // a Date instance
onFavorite: () => toggleFavorite(item.id), // a function
},
});
};It reads well. The detail screen just calls params.onFavorite(). Nothing about it caused trouble in development.
The catch is that navigation state gets converted to JSON so it can be persisted.
JSON.stringify drops functions silently. Dates become strings. So after restoration, params actually held this:
// What was really there after resume
{
item: "[object Object]", // stringified into nothing useful
publishedAt: "2026-07-01T00:00:00.000Z", // a string, not a Date
onFavorite: undefined, // gone
}onFavorite is undefined, and calling it throws. Anything doing publishedAt.getTime() breaks the same way.
And the warning I had been seeing all along:
Non-serializable values were found in the navigation state.
Check: wallpaper/detail > params.onFavorite
React Navigation had been telling me from day one. I had filed it under "it works, I'll look later." I had simply never connected it to state restoration.
The fix — put nothing but identifiers in params
There are several ways through this, but the ordering is not ambiguous.
Fix 1: only identifiers in route params (recommended)
The dullest option, and the one that actually works. Decide that params may hold strings, numbers, and booleans — nothing else.
// Navigating
const openDetail = (item: Wallpaper) => {
router.push({
pathname: "/wallpaper/detail",
params: { id: item.id }, // that's all
});
};// Receiving: app/wallpaper/detail.tsx
import { useLocalSearchParams } from "expo-router";
import { useWallpaperStore } from "@/store/wallpaper";
export default function WallpaperDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const item = useWallpaperStore((s) => s.byId[id]);
const toggleFavorite = useWallpaperStore((s) => s.toggleFavorite);
// Right after restoration the store may not be hydrated yet
if (!item) return <DetailSkeleton />;
return (
<DetailView
item={item}
publishedAt={new Date(item.publishedAt)}
onFavorite={() => toggleFavorite(id)}
/>
);
}Look the object back up from the ID. Keep the callback in the store. Now, however violently the process dies, one surviving string is enough to rebuild the screen.
The reason identifiers are safe is that an identifier can live in a URL. If a deep link hits /wallpaper/detail?id=zen-042 from outside, the screen still works. That shape and the restoration-proof shape are the same shape. Once that clicked, the decision stopped being a judgment call.
Do not skip the if (!item) return <DetailSkeleton /> line. Store hydration can lag a restore, and without that guard you simply move the crash to item.uri. I forgot it on my first pass and briefly celebrated a crash that had merely changed its name.
Fix 2: pass Dates as ISO strings and rebuild them
If a timestamp genuinely has to travel in params, send a string.
router.push({
pathname: "/wallpaper/detail",
params: { id: item.id, publishedAt: item.publishedAt.toISOString() },
});
// Receiving
const { publishedAt } = useLocalSearchParams<{ publishedAt: string }>();
const date = new Date(publishedAt);Both the outbound and the restored path go through the same conversion, so the value survives.
Fix 3: make the warning impossible to ignore
The root problem was that the warning stayed yellow. In development, I promoted it to an exception.
// app/_layout.tsx
if (__DEV__) {
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
const msg = String(args[0] ?? "");
if (msg.includes("Non-serializable values were found")) {
throw new Error(`[navigation] ${msg}`);
}
origWarn(...args);
};
}Blunt, but decisive. From then on, the moment generated code reaches for that pattern, it stops on my machine. Production is untouched because __DEV__ is false there.
Prevention — run generated code through a resume before accepting it
None of this is Rork's fault. The generated code is a reasonable, readable way to express the intent, and it does work while you are building. What the generator cannot know is that the process will die and the screen will be rebuilt from a JSON blob. Handling that is the receiving side's job, and I prefer to treat it that way.
I added one line to my device checklist:
- Walk the main screens with "Don't keep activities" left on
About 3 minutes per app. Under 20 minutes across all six. Since adding it, resume crashes have stopped showing up in Crashlytics.
Pinning down the types at the navigation boundary helps too — deviations in generated code surface immediately.
// Declare what's allowed, and let the compiler hold the line
type SerializableParams = Record<string, string | number | boolean>;
export const pushDetail = (params: { id: string }) =>
router.push({ pathname: "/wallpaper/detail", params });Funnel navigation through functions like this, and the moment generated code tries to hand over an object, you get a type error instead of a Crashlytics entry.
A triage table
When something similar turns up, this is the order I check:
| Symptom | Where to look |
|---|---|
is not a function, only on resume | A function is riding in params |
getTime is not a function, only on resume | A Date in params became a string |
| Blank screen, only on resume | You are not re-fetching by ID |
[object Object] after resume | An object is riding in params |
| Crashes every time | Different problem — restoration is not involved |
If the words "only on resume" fit, it is almost certainly this family.
What to do next
Start by checking your development console for Non-serializable values were found. Every screen it names is a candidate for crashing on resume.
Then reshape those navigation calls to carry an ID and nothing more. Those two steps closed the case for me.
If you are reworking the navigation structure itself, the Expo Router design walkthrough is a good foundation. For state that has to survive a process death, the piece on wallpaper grids jumping back to the top covers the save-and-restore timing in more depth.
Quiet work — clearing one warning. But watching an unreproducible crash disappear was a good feeling.