Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
ddecrulle committed May 29, 2024
2 parents b30cab9 + cc8e11f commit d851f39
Show file tree
Hide file tree
Showing 10 changed files with 369 additions and 31 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = {
rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "off"
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-namespace": "off"
}
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@tanstack/react-router": "^1.33.6",
"axios": "^1.7.1",
"dayjs": "^1.11.11",
"lodash": "^4.17.21",
"oidc-spa": "^4.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
10 changes: 6 additions & 4 deletions src/components/TodoApp/Todo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { tss } from "tss-react";
import { fr } from "@codegouvfr/react-dsfr";
import { Button } from "@codegouvfr/react-dsfr/Button";
import Checkbox from "@mui/material/Checkbox";
import { useConstCallback } from "tools/useConstCallback";
import { useEvent } from "tools/useEvent";
import { deepEqual } from "tools/deepEqual";

export type Todo = {
id: string;
Expand All @@ -23,9 +24,9 @@ export const Todo = memo((props: TodoProps) => {
const { className, todo, onToggleTodo, onDeleteTodo } = props;

// NOTE: Make sure it's not stale for when used in the reducer.
// We know it's constant because we also used useListCallbacks() in the parent component
// We know it's constant because we also used useListEvent() in the parent component
// but this component is not supposed to be aware of that.
const onUpdateTodoText = useConstCallback(props.onUpdateTodoText);
const onUpdateTodoText = useEvent(props.onUpdateTodoText);

const [isEditing, setIsEditing] = useReducer((isEditing: boolean, isEditing_new: boolean) => {
if (isEditing_new === isEditing) {
Expand Down Expand Up @@ -80,7 +81,8 @@ export const Todo = memo((props: TodoProps) => {
</div>
</div>
);
});
}, deepEqual);
// NOTE: We use deepEqual above to avoid having the component re-render if the ref of the todo has changed but it's actually the same todo.

const useStyles = tss
.withName({ Todo })
Expand Down
34 changes: 28 additions & 6 deletions src/components/TodoApp/TodoApp.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Todo } from "./Todo";
import { AddTodo } from "./AddTodo";
import { tss } from "tss-react";
import { useListCallbacks } from "tools/useListCallbacks";
import { usePartiallyAppliedEvent } from "tools/usePartiallyAppliedEvent";

type Props = {
className?: string;
Expand All @@ -18,23 +18,45 @@ export function TodoApp(props: Props) {
const { classes, cx } = useStyles();

/*
Example:
```ts
const todoId= "123"
const onUpdateEvent = getOnUpdateTodoText(todoId);
const text = "Hello"
onUpdateEvent(text) // Will call onUpdateTodoText(todoId, text);
```
Why is it useful? Because:
`getOnUpdateTodoText(todoId) === getOnUpdateTodoText(todoId)` // is true
The function reference returned by `getOnUpdateTodoText(todoId)` is stable across re-renders.
If we use this custom hook instead of just doing:
onToggleTodo={()=> onToggleTodo(todo.id)}
onUpdateTodoText={()=> onUpdateTodoText(todo.id, todo.text)}
It is because we want to avoid all <Todo /> to be re-rendered every time this component is re-rendered.
For that we use memo() on the Todo component but we also need to make sure that the references of the callbacks
are stable.
Learn more: https://stackblitz.com/edit/react-ts-fyrwng?file=index.tsx
Hot take: The builtin useCallback() hook should never be used. In any scenario.
It almost never enables to avoid rerender and is very error prone. It shouldn't exist in the first place.
https://stackoverflow.com/questions/65890278/why-cant-usecallback-always-return-the-same-ref
Note: This is the state of the art for React 18. React 19 shuffles the deck with it's pre-compiler.
Note: This is the state of the art for React 18. React 19 shuffles the deck with it's pre-compiler
however there's only so much the compiler will be able to infer. It's important to be able to manually
manage our re-rendering strategy.
*/
const getOnUpdateTodoText = useListCallbacks(([todoId]: [string], [text]: [string]) =>
const getOnUpdateTodoText = usePartiallyAppliedEvent(([todoId]: [string], [text]: [string]) =>
onUpdateTodoText(todoId, text)
);
const getOnToggleTodo = useListCallbacks(([todoId]: [string]) => onToggleTodo(todoId));
const getOnDeleteTodo = useListCallbacks(([todoId]: [string]) => onDeleteTodo(todoId));
const getOnToggleTodo = usePartiallyAppliedEvent(([todoId]: [string]) => onToggleTodo(todoId));
const getOnDeleteTodo = usePartiallyAppliedEvent(([todoId]: [string]) => onDeleteTodo(todoId));

return (
<div className={cx(classes.root, className)}>
Expand Down
237 changes: 237 additions & 0 deletions src/tools/deepEqual/deepEqual.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/* eslint-disable @typescript-eslint/ban-types */
import { MapLike, SetLike, DateLike, ArrayLike } from "./types";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";

/**
* Function that perform a in depth comparison of two things of arbitrary type T
* to see if they represent the deepEqual date regardless of object references.
*
* Think of it as JSON.stringify(o1) === JSON.stringify(o2)
* but unlike a test performed with JSON.stringify the order in the property
* have been assigned to an object does not matter and circular references are supported.
*
*
* If takeIntoAccountArraysOrdering === false then
* representsSameData(["a", "b"], ["b", "a"]) will return true.
*
* If Date are compared via .getTime()
*
* The objects can includes Map and Set.
* */
export const deepEqual = (() => {
function sameRec<T>(
o1: T,
o2: T,
{ takeIntoAccountArraysOrdering }: { takeIntoAccountArraysOrdering: boolean } = {
takeIntoAccountArraysOrdering: true
},
o1Path: { key: string; obj: any }[],
o2Path: { key: string; obj: any }[],
o1RealRef: T = o1,
o2RealRef: T = o2
): boolean {
if (Object.is(o1, o2)) {
return true;
}

{
const i1 = o1Path.map(({ obj }) => obj).indexOf(o1RealRef);

if (i1 >= 0) {
const i2 = o2Path.map(({ obj }) => obj).indexOf(o2RealRef);

if (i1 !== i2) {
return false;
}

{
const [a, b] = ([o1Path, o2Path] as const).map(oPath =>
oPath.map(({ key }) => key).join("")
);

return a === b;
}
}
}

if (!(o1 instanceof Object && o2 instanceof Object)) {
return false;
}

if (typeof o1 === "function" || typeof o2 === "function") {
return false;
}

if (DateLike.match(o1)) {
if (!DateLike.match(o2)) {
return false;
}

return o1.getTime() === o2!.getTime();
}

if (MapLike.match<any, any>(o1)) {
if (!MapLike.match<any, any>(o2)) {
return false;
}

type Entry = { key: any; value: any };

const newO1 = new Set<Entry>();
const newO2 = new Set<Entry>();

for (const o of [o1, o2]) {
const newO = o === o1 ? newO1 : newO2;

const arr = Array.from(o.keys());

for (let i = 0; i < arr.length; i++) {
const key = arr[i];
const value = o.get(key)!;

newO.add({ key, value });
}
}

return sameRec(
newO1,
newO2,
{ takeIntoAccountArraysOrdering },
o1Path,
o2Path,
o1RealRef as any,
o2RealRef
);
}

let takeIntoAccountArraysOrderingOv: false | undefined = undefined;

if (SetLike.match(o1)) {
if (!SetLike.match(o2)) {
return false;
}

o1 = Array.from(o1.values()) as any;
o2 = Array.from(o2.values()) as any;

takeIntoAccountArraysOrderingOv = false;
}

//NOTE: The two following lines shouldn't be necessary...
assert(is<Object>(o1));
assert(is<Object>(o2));

if (ArrayLike.match<any>(o1)) {
if (!ArrayLike.match<any>(o2)) {
return false;
}

if (o1.length !== o2.length) {
return false;
}

if (
!(takeIntoAccountArraysOrderingOv !== undefined
? takeIntoAccountArraysOrderingOv
: takeIntoAccountArraysOrdering)
) {
const o2Set = new Set(Array.from(o2));

for (let i = 0; i < o1.length; i++) {
if (!(`${i}` in o1)) {
continue;
}

const val1 = o1[i];

if (o2Set.has(val1)) {
o2Set.delete(val1);
continue;
}

let isFound = false;

for (const val2 of o2Set.values()) {
if (
!sameRec(
val1,
val2,
{ takeIntoAccountArraysOrdering },
[...o1Path, { obj: o1RealRef, key: "*" }],
[...o2Path, { obj: o2RealRef, key: "*" }]
)
) {
continue;
}

isFound = true;
o2Set.delete(val2);
break;
}

if (!isFound) {
return false;
}
}

return true;
}

//continue
} else if (
!sameRec(
Object.keys(o1).filter(key => (o1 as any)[key] !== undefined),
Object.keys(o2).filter(key => (o2 as any)[key] !== undefined),
{ takeIntoAccountArraysOrdering: false },
[],
[]
)
) {
return false;
}

for (const key in o1) {
if (
!sameRec(
(o1 as any)[key],
(o2 as any)[key],
{ takeIntoAccountArraysOrdering },
[...o1Path, { obj: o1RealRef, key }],
[...o2Path, { obj: o2RealRef, key }]
)
) {
return false;
}
}

return true;
}

return function deepEqual<T>(
o1: T,
o2: T,
{ takeIntoAccountArraysOrdering }: { takeIntoAccountArraysOrdering: boolean } = {
takeIntoAccountArraysOrdering: true
}
): boolean {
return sameRec(o1, o2, { takeIntoAccountArraysOrdering }, [], []);
};
})();

/**
* Return the "deepEqual" function with "takeIntoAccountArraysOrdering" default value set as desired.
* */
export function deepEqualFactory({
takeIntoAccountArraysOrdering
}: {
takeIntoAccountArraysOrdering: boolean;
}) {
return {
deepEqual: <T>(
o1: T,
o2: T,
prop: { takeIntoAccountArraysOrdering: boolean } = { takeIntoAccountArraysOrdering }
) => deepEqual(o1, o2, prop)
};
}
27 changes: 27 additions & 0 deletions src/tools/deepEqual/getPrototypeChain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* eslint-disable @typescript-eslint/ban-types */

export function getPrototypeChain(obj: Object, callback?: (proto: Object) => boolean): Object[] {
const proto = Object.getPrototypeOf(obj);

if (!proto) {
return [];
}

const doContinue = callback?.(proto);

if (!doContinue) {
return [proto];
}

return [proto, ...getPrototypeChain(proto)];
}
getPrototypeChain.isMatched = (obj: Object, regExp: RegExp): boolean => {
let out = false;

getPrototypeChain(obj, ({ constructor }) => {
out = regExp.test(constructor.name);
return !out;
});

return out;
};
1 change: 1 addition & 0 deletions src/tools/deepEqual/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./deepEqual";
Loading

0 comments on commit d851f39

Please sign in to comment.