fix: improve the warning message for non-serializable values

This commit is contained in:
Satyajit Sahoo
2020-07-10 15:32:18 +02:00
parent eea9860323
commit e63580edbe
4 changed files with 177 additions and 68 deletions

View File

@@ -17,7 +17,7 @@ import useKeyedChildListeners from './useKeyedChildListeners';
import useOptionsGetters from './useOptionsGetters';
import useEventEmitter from './useEventEmitter';
import useSyncState from './useSyncState';
import isSerializable from './isSerializable';
import checkSerializable from './checkSerializable';
import type {
NavigationContainerEventMap,
NavigationContainerRef,
@@ -29,7 +29,7 @@ type State = NavigationState | PartialState<NavigationState> | undefined;
const NOT_INITIALIZED_ERROR =
"The 'navigation' object hasn't been initialized yet. This might happen if you don't have a navigator mounted, or if the navigator hasn't finished mounting. See https://reactnavigation.org/docs/navigating-without-navigation-prop#handling-initialization for more details.";
let hasWarnedForSerialization = false;
const serializableWarnings: string[] = [];
try {
/**
@@ -267,16 +267,55 @@ const BaseNavigationContainer = React.forwardRef(
React.useEffect(() => {
if (process.env.NODE_ENV !== 'production') {
if (
state !== undefined &&
!isSerializable(state) &&
!hasWarnedForSerialization
) {
hasWarnedForSerialization = true;
if (state !== undefined) {
const result = checkSerializable(state);
console.warn(
"Non-serializable values were found in the navigation state, which can break usage such as persisting and restoring state. This might happen if you passed non-serializable values such as function, class instances etc. in params. If you need to use components with callbacks in your options, you can use 'navigation.setOptions' instead. See https://reactnavigation.org/docs/troubleshooting#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state for more details."
);
if (!result.serializable) {
const { location, reason } = result;
let path = '';
let pointer: Record<any, any> = state;
let params = false;
for (let i = 0; i < location.length; i++) {
const curr = location[i];
const prev = location[i - 1];
pointer = pointer[curr];
if (!params && curr === 'state') {
continue;
} else if (!params && curr === 'routes') {
if (path) {
path += ' > ';
}
} else if (
!params &&
typeof curr === 'number' &&
prev === 'routes'
) {
path += pointer?.name;
} else if (!params) {
path += ` > ${curr}`;
params = true;
} else {
if (typeof curr === 'number' || /^[0-9]+$/.test(curr)) {
path += `[${curr}]`;
} else if (/^[a-z$_]+$/i.test(curr)) {
path += `.${curr}`;
} else {
path += `[${JSON.stringify(curr)}]`;
}
}
}
const message = `Non-serializable values were found in the navigation state. Check:\n\n${path} (${reason})\n\nThis can break usage such as persisting and restoring state. This might happen if you passed non-serializable values such as function, class instances etc. in params. If you need to use components with callbacks in your options, you can use 'navigation.setOptions' instead. See https://reactnavigation.org/docs/troubleshooting#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state for more details.`;
if (!serializableWarnings.includes(message)) {
serializableWarnings.push(message);
console.warn(message);
}
}
}
}

View File

@@ -1,8 +1,8 @@
import isSerializable from '../isSerializable';
import checkSerializable from '../checkSerializable';
it('returns true for serializable object', () => {
expect(
isSerializable({
checkSerializable({
index: 0,
key: '7',
routeNames: ['foo', 'bar'],
@@ -22,12 +22,12 @@ it('returns true for serializable object', () => {
},
],
})
).toBe(true);
).toEqual({ serializable: true });
});
it('returns false for non-serializable object', () => {
expect(
isSerializable({
checkSerializable({
index: 0,
key: '7',
routeNames: ['foo', 'bar'],
@@ -47,7 +47,38 @@ it('returns false for non-serializable object', () => {
},
],
})
).toBe(false);
).toEqual({
serializable: false,
location: ['routes', 0, 'state', 'routes', 0, 'params'],
reason: 'Function',
});
expect(
checkSerializable({
index: 0,
key: '7',
routeNames: ['foo', 'bar'],
routes: [
{
key: 'foo',
name: 'foo',
state: {
index: 0,
key: '8',
routeNames: ['qux', 'lex'],
routes: [
{ key: 'qux', name: 'qux', params: { foo: Symbol('test') } },
{ key: 'lex', name: 'lex' },
],
},
},
],
})
).toEqual({
serializable: false,
location: ['routes', 0, 'state', 'routes', 0, 'params', 'foo'],
reason: 'Symbol(test)',
});
});
it('returns false for circular references', () => {
@@ -59,7 +90,11 @@ it('returns false for circular references', () => {
x.b.b2 = x;
x.c = x.b;
expect(isSerializable(x)).toBe(false);
expect(checkSerializable(x)).toEqual({
serializable: false,
location: ['b', 'b2'],
reason: 'Circular reference',
});
const y: any = [
{
@@ -72,7 +107,11 @@ it('returns false for circular references', () => {
y[0].children[0].parent = y[0];
y[1].extend.home = y[0].children[0];
expect(isSerializable(y)).toBe(false);
expect(checkSerializable(y)).toEqual({
serializable: false,
location: [0, 'children', 0, 'parent'],
reason: 'Circular reference',
});
const z: any = {
name: 'sun',
@@ -81,14 +120,18 @@ it('returns false for circular references', () => {
z.child[0].parent = z;
expect(isSerializable(z)).toBe(false);
expect(checkSerializable(z)).toEqual({
serializable: false,
location: ['child', 0, 'parent'],
reason: 'Circular reference',
});
});
it("doesn't fail if same object used multiple times", () => {
const o = { foo: 'bar' };
expect(
isSerializable({
checkSerializable({
baz: 'bax',
first: o,
second: o,
@@ -96,5 +139,5 @@ it("doesn't fail if same object used multiple times", () => {
b: o,
},
})
).toBe(true);
).toEqual({ serializable: true });
});

View File

@@ -0,0 +1,74 @@
const checkSerializableWithoutCircularReference = (
o: { [key: string]: any },
seen: Set<any>,
location: (string | number)[]
):
| { serializable: true }
| {
serializable: false;
location: (string | number)[];
reason: string;
} => {
if (
o === undefined ||
o === null ||
typeof o === 'boolean' ||
typeof o === 'number' ||
typeof o === 'string'
) {
return { serializable: true };
}
if (
Object.prototype.toString.call(o) !== '[object Object]' &&
!Array.isArray(o)
) {
return {
serializable: false,
location,
reason: typeof o === 'function' ? 'Function' : String(o),
};
}
if (seen.has(o)) {
return {
serializable: false,
reason: 'Circular reference',
location,
};
}
seen.add(o);
if (Array.isArray(o)) {
for (let i = 0; i < o.length; i++) {
const childResult = checkSerializableWithoutCircularReference(
o[i],
new Set<any>(seen),
[...location, i]
);
if (!childResult.serializable) {
return childResult;
}
}
} else {
for (const key in o) {
const childResult = checkSerializableWithoutCircularReference(
o[key],
new Set<any>(seen),
[...location, key]
);
if (!childResult.serializable) {
return childResult;
}
}
}
return { serializable: true };
};
export default function checkSerializable(o: { [key: string]: any }) {
return checkSerializableWithoutCircularReference(o, new Set<any>(), []);
}

View File

@@ -1,47 +0,0 @@
const isSerializableWithoutCircularReference = (
o: { [key: string]: any },
seen: Set<any>
): boolean => {
if (
o === undefined ||
o === null ||
typeof o === 'boolean' ||
typeof o === 'number' ||
typeof o === 'string'
) {
return true;
}
if (
Object.prototype.toString.call(o) !== '[object Object]' &&
!Array.isArray(o)
) {
return false;
}
if (seen.has(o)) {
return false;
}
seen.add(o);
if (Array.isArray(o)) {
for (const it of o) {
if (!isSerializableWithoutCircularReference(it, new Set<any>(seen))) {
return false;
}
}
} else {
for (const key in o) {
if (!isSerializableWithoutCircularReference(o[key], new Set<any>(seen))) {
return false;
}
}
}
return true;
};
export default function isSerializable(o: { [key: string]: any }) {
return isSerializableWithoutCircularReference(o, new Set<any>());
}