rl-core-front 0.18.6 → 0.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/_services/api/axios.factory.ts +3 -0
  3. package/src/_utils/storage.ts +31 -0
  4. package/src/components/app-shell.tsx +173 -114
  5. package/src/components/ui/column-visibility-modal.tsx +11 -4
  6. package/src/components/ui/data-table.tsx +49 -9
  7. package/src/components/ui/filter-sheet.tsx +9 -3
  8. package/src/contexts/auth-context.tsx +6 -1
  9. package/src/contexts/color-mode-context.tsx +29 -22
  10. package/src/contexts/i18n-context.tsx +5 -15
  11. package/src/contexts/socket-context.tsx +10 -1
  12. package/src/features/audit/components/record-audit-button.tsx +3 -1
  13. package/src/features/audit/hooks/use-audit-trail.ts +3 -1
  14. package/src/features/logs/hooks/use-request-logs.ts +3 -1
  15. package/src/features/notifications/hooks/use-notifications.ts +3 -1
  16. package/src/features/profile/profile-screen.tsx +3 -1
  17. package/src/features/queues/hooks/use-queue-jobs.ts +3 -1
  18. package/src/features/rbac/panels/catalog-panel.tsx +3 -1
  19. package/src/features/rbac/panels/groups-panel.tsx +3 -1
  20. package/src/features/rbac/panels/permissions-panel.tsx +3 -1
  21. package/src/features/rbac/panels/roles-panel.tsx +8 -4
  22. package/src/features/recovery/reset-password-screen.tsx +17 -5
  23. package/src/features/users/hooks/use-users.ts +3 -1
  24. package/src/hooks/use-column-visibility.ts +30 -18
  25. package/src/hooks/use-document-title.ts +49 -18
  26. package/src/hooks/use-filter-schema.ts +3 -1
  27. package/src/hooks/use-filters.ts +36 -10
  28. package/src/hooks/use-is-hydrated.ts +21 -0
  29. package/src/hooks/use-job-progress.ts +11 -4
  30. package/src/hooks/use-list-query.ts +9 -4
  31. package/src/hooks/use-stored-value.ts +35 -0
  32. package/src/hooks/use-tab-state.ts +62 -28
  33. package/src/hooks/use-table-view.ts +15 -17
@@ -68,6 +68,7 @@ export function SocketProvider({
68
68
  }): JSX.Element {
69
69
  const { user, refreshProfile } = useAuth();
70
70
  const [status, setStatus] = useState<SocketStatus>("disconnected");
71
+ const [lastUserId, setLastUserId] = useState<string | undefined>(undefined);
71
72
  const [reconnectCount, setReconnectCount] = useState(0);
72
73
  const socketRef = useRef<Socket | null>(null);
73
74
 
@@ -85,12 +86,20 @@ export function SocketProvider({
85
86
  // socket.io entraria em ciclo de retentativa.
86
87
  const userId = user?.id;
87
88
 
89
+ // Quem manda no estado é a sessão: com um usuário novo há uma conexão nova a
90
+ // abrir, e o "conectando" é consequência disso — não do efeito, que só vai
91
+ // rodar depois da pintura e deixaria a barra dizendo "desconectado" por um
92
+ // quadro.
93
+ if (userId !== lastUserId) {
94
+ setLastUserId(userId);
95
+ setStatus(userId ? "connecting" : "disconnected");
96
+ }
97
+
88
98
  useEffect(() => {
89
99
  if (!userId) {
90
100
  return;
91
101
  }
92
102
 
93
- setStatus("connecting");
94
103
  const socket = io(`${SOCKET_URL}${NAMESPACE}`, {
95
104
  withCredentials: true,
96
105
  transports: ["websocket"],
@@ -89,7 +89,9 @@ export function RecordAuditButton({
89
89
  // clica no histórico.
90
90
  useEffect(() => {
91
91
  if (open) {
92
- void fetch();
92
+ void (async (): Promise<void> => {
93
+ await fetch();
94
+ })();
93
95
  }
94
96
  }, [open, fetch]);
95
97
 
@@ -57,7 +57,9 @@ export function useAuditTrail(): UseAuditTrailResult {
57
57
  if (!list.ready) {
58
58
  return;
59
59
  }
60
- void fetch();
60
+ void (async (): Promise<void> => {
61
+ await fetch();
62
+ })();
61
63
  }, [fetch, list.ready]);
62
64
 
63
65
  return {
@@ -75,7 +75,9 @@ export function useRequestLogs(): UseRequestLogsResult {
75
75
  if (!list.ready) {
76
76
  return;
77
77
  }
78
- void fetch();
78
+ void (async (): Promise<void> => {
79
+ await fetch();
80
+ })();
79
81
  }, [fetch, list.ready]);
80
82
 
81
83
  return {
@@ -44,7 +44,9 @@ export function useNotifications(): UseNotificationsResult {
44
44
  // Carga inicial e, de novo, a cada reconexão: enquanto o socket esteve fora
45
45
  // chegaram notificações que ninguém empurrou para esta aba.
46
46
  useEffect(() => {
47
- void fetch();
47
+ void (async (): Promise<void> => {
48
+ await fetch();
49
+ })();
48
50
  }, [fetch, reconnectCount]);
49
51
 
50
52
  // Sem toast aqui: o badge do sino já é o aviso, e disparar os dois pelo mesmo
@@ -83,7 +83,9 @@ export function ProfileScreen(): JSX.Element {
83
83
  }
84
84
  };
85
85
  useEffect(() => {
86
- void loadMe();
86
+ void (async (): Promise<void> => {
87
+ await loadMe();
88
+ })();
87
89
  // eslint-disable-next-line react-hooks/exhaustive-deps
88
90
  }, []);
89
91
 
@@ -116,7 +116,9 @@ export function useQueueJobs(): UseQueueJobsResult {
116
116
  }, [latest, run, state, queue, page, pageSize, sorting]);
117
117
 
118
118
  useEffect(() => {
119
- void fetch();
119
+ void (async (): Promise<void> => {
120
+ await fetch();
121
+ })();
120
122
  }, [fetch]);
121
123
 
122
124
  const setState = (next: QueueJobState): void => {
@@ -70,7 +70,9 @@ export function CatalogPanel({
70
70
  if (r) {setRows(r);}
71
71
  };
72
72
  useEffect(() => {
73
- void load();
73
+ void (async (): Promise<void> => {
74
+ await load();
75
+ })();
74
76
  // eslint-disable-next-line react-hooks/exhaustive-deps
75
77
  }, []);
76
78
 
@@ -76,7 +76,9 @@ export function GroupsPanel(): JSX.Element {
76
76
  if (u) {setCandidates(u.items);}
77
77
  };
78
78
  useEffect(() => {
79
- void load();
79
+ void (async (): Promise<void> => {
80
+ await load();
81
+ })();
80
82
  // eslint-disable-next-line react-hooks/exhaustive-deps
81
83
  }, []);
82
84
 
@@ -40,7 +40,9 @@ export function PermissionsPanel(): JSX.Element {
40
40
  if (r) {setRows(r);}
41
41
  };
42
42
  useEffect(() => {
43
- void load();
43
+ void (async (): Promise<void> => {
44
+ await load();
45
+ })();
44
46
  // eslint-disable-next-line react-hooks/exhaustive-deps
45
47
  }, []);
46
48
 
@@ -4,7 +4,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
4
4
  import { Pencil, Plus, Trash2 } from "lucide-react";
5
5
  import type { JSX } from "react";
6
6
  import { useEffect, useMemo, useState } from "react";
7
- import { useForm } from "react-hook-form";
7
+ import { useForm, useWatch } from "react-hook-form";
8
8
  import * as yup from "yup";
9
9
 
10
10
  import {
@@ -62,14 +62,16 @@ export function RolesPanel(): JSX.Element {
62
62
  handleSubmit,
63
63
  reset,
64
64
  setValue,
65
- watch,
65
+ control,
66
66
  formState: { errors },
67
67
  } = useForm<RoleFormValues>({
68
68
  resolver: yupResolver(schema),
69
69
  defaultValues: { name: "", description: "", permissionIds: [] },
70
70
  });
71
71
 
72
- const permissionIds = watch("permissionIds");
72
+ // `useWatch` e não `watch`: o segundo devolve uma função nova a cada render,
73
+ // e com ela o React Compiler desiste de memoizar o painel inteiro.
74
+ const permissionIds = useWatch({ control, name: "permissionIds" });
73
75
 
74
76
  const load = async (): Promise<void> => {
75
77
  const [r, p] = await Promise.all([
@@ -80,7 +82,9 @@ export function RolesPanel(): JSX.Element {
80
82
  if (p) {setPerms(p);}
81
83
  };
82
84
  useEffect(() => {
83
- void load();
85
+ void (async (): Promise<void> => {
86
+ await load();
87
+ })();
84
88
  // eslint-disable-next-line react-hooks/exhaustive-deps
85
89
  }, []);
86
90
 
@@ -24,6 +24,9 @@ import {
24
24
  import { useI18n } from "#core/contexts";
25
25
  import { useRequest } from "#core/hooks/use-request";
26
26
 
27
+ /** O que se mostra quando o link não tem token ou o servidor o recusa. */
28
+ const INVALID_TOKEN: PasswordTokenCheck = { valid: false, status: "invalid" };
29
+
27
30
  const INVALID_MESSAGE_KEY: Record<PasswordTokenStatus, string> = {
28
31
  valid: "recovery.linkInvalid",
29
32
  invalid: "recovery.linkInvalid",
@@ -38,22 +41,31 @@ export function ResetPasswordScreen(): JSX.Element {
38
41
  const { run, loading, error } = useRequest({ silent: true });
39
42
  const { t } = useI18n();
40
43
  const [done, setDone] = useState(false);
41
- const [check, setCheck] = useState<PasswordTokenCheck | null>(null);
42
- const [checking, setChecking] = useState(true);
44
+ // Link sem token nenhum nasce recusado: não há o que consultar, e passar
45
+ // por um efeito mostraria o "verificando" de um link que nunca foi válido.
46
+ const [check, setCheck] = useState<PasswordTokenCheck | null>(
47
+ token ? null : INVALID_TOKEN,
48
+ );
49
+ const [checking, setChecking] = useState(Boolean(token));
50
+ const [lastToken, setLastToken] = useState(token);
51
+
52
+ if (token !== lastToken) {
53
+ setLastToken(token);
54
+ setCheck(token ? null : INVALID_TOKEN);
55
+ setChecking(Boolean(token));
56
+ }
43
57
 
44
58
  // Valida o link ANTES de exibir o formulário — token ausente, expirado,
45
59
  // já usado ou inexistente nunca chega a renderizar os campos de senha.
46
60
  useEffect(() => {
47
61
  if (!token) {
48
- setCheck({ valid: false, status: "invalid" });
49
- setChecking(false);
50
62
  return;
51
63
  }
52
64
  let alive = true;
53
65
  authService
54
66
  .inspectPasswordToken(token)
55
67
  .then((res) => alive && setCheck(res))
56
- .catch(() => alive && setCheck({ valid: false, status: "invalid" }))
68
+ .catch(() => alive && setCheck(INVALID_TOKEN))
57
69
  .finally(() => alive && setChecking(false));
58
70
  return () => {
59
71
  alive = false;
@@ -82,7 +82,9 @@ export function useUsers(): UseUsersResult {
82
82
  if (!list.ready) {
83
83
  return;
84
84
  }
85
- void fetch();
85
+ void (async (): Promise<void> => {
86
+ await fetch();
87
+ })();
86
88
  }, [fetch, list.ready]);
87
89
 
88
90
  useEffect(() => {
@@ -1,11 +1,15 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useState } from "react";
3
+ import { useCallback, useMemo, useState } from "react";
4
4
 
5
5
  import { safeStorage } from "#core/_utils/storage";
6
+ import { useStoredValue } from "#core/hooks/use-stored-value";
6
7
 
7
8
  const STORAGE_PREFIX = "cols:";
8
9
 
10
+ /** Nada escondido: uma referência só, para não re-renderizar quem observa. */
11
+ const NONE: string[] = [];
12
+
9
13
  export interface UseColumnVisibilityResult {
10
14
  hiddenColumns: string[];
11
15
  isColumnVisible: (columnId: string) => boolean;
@@ -24,20 +28,29 @@ export function useColumnVisibility(
24
28
  storageKey?: string,
25
29
  defaultHidden: string[] = [],
26
30
  ): UseColumnVisibilityResult {
27
- const [hiddenColumns, setHiddenColumns] = useState<string[]>(defaultHidden);
31
+ const key = storageKey ? `${STORAGE_PREFIX}${storageKey}` : undefined;
32
+ const stored = useStoredValue(key);
33
+ const [sessionHidden, setSessionHidden] = useState<string[]>(defaultHidden);
34
+
35
+ // A lista do primeiro render: vinda como parâmetro, ela é outro array a cada
36
+ // render, e devolvê-la direto faria a tabela inteira se achar mudada.
37
+ const [fallback] = useState(defaultHidden);
28
38
 
29
- useEffect(() => {
30
- if (!storageKey) {return;}
31
- const raw = safeStorage.get(`${STORAGE_PREFIX}${storageKey}`);
32
- if (!raw) {return;}
39
+ const hiddenColumns = useMemo<string[]>(() => {
40
+ if (!key) {
41
+ return sessionHidden;
42
+ }
43
+ if (!stored) {
44
+ return fallback;
45
+ }
33
46
  try {
34
- // O `try` que sobrou é do JSON: o acesso ao armazenamento já é seguro,
35
- // mas o conteúdo pode estar corrompido por uma versão anterior da tela.
36
- setHiddenColumns(JSON.parse(raw));
47
+ // O `try` é do JSON: o acesso ao armazenamento já é seguro, mas o
48
+ // conteúdo pode estar corrompido por uma versão anterior da tela.
49
+ return JSON.parse(stored) as string[];
37
50
  } catch {
38
- setHiddenColumns([]);
51
+ return NONE;
39
52
  }
40
- }, [storageKey]);
53
+ }, [key, stored, sessionHidden, fallback]);
41
54
 
42
55
  const isColumnVisible = useCallback(
43
56
  (columnId: string) => !hiddenColumns.includes(columnId),
@@ -46,14 +59,13 @@ export function useColumnVisibility(
46
59
 
47
60
  const saveHiddenColumns = useCallback(
48
61
  (nextHidden: string[]) => {
49
- setHiddenColumns(nextHidden);
50
- if (!storageKey) {return;}
51
- safeStorage.set(
52
- `${STORAGE_PREFIX}${storageKey}`,
53
- JSON.stringify(nextHidden),
54
- );
62
+ if (!key) {
63
+ setSessionHidden(nextHidden);
64
+ return;
65
+ }
66
+ safeStorage.set(key, JSON.stringify(nextHidden));
55
67
  },
56
- [storageKey],
68
+ [key],
57
69
  );
58
70
 
59
71
  return { hiddenColumns, isColumnVisible, saveHiddenColumns };
@@ -2,39 +2,70 @@
2
2
 
3
3
  import { useEffect } from "react";
4
4
 
5
+ /** O que fica entre o nome do sistema e o da tela. */
6
+ const SEPARATOR = " - ";
7
+
5
8
  /**
6
- * O título com que a página carregou — o que o `metadata` do projeto mandou.
9
+ * O nome do sistema — o que o `metadata` do projeto mandou.
7
10
  *
8
- * Lido uma vez por carregamento e nunca reescrito: é o sufixo de todos os
9
- * títulos daqui em diante, e o projeto não precisa dizer duas vezes como se
10
- * chama (o prefixo "Dev - " que ele põe no `metadata` vem junto de graça).
11
+ * Sai do próprio `document.title`, e por isso o projeto não precisa dizer duas
12
+ * vezes como se chama (o prefixo "Dev - " que ele põe no `metadata` vem junto
13
+ * de graça). É relido sempre que o título muda por fora: quem escreve por fora
14
+ * é o App Router, e o que ele escreve é justamente o `metadata` da rota.
11
15
  */
12
- let baseTitle: string | null = null;
16
+ let baseTitle = "";
13
17
 
14
- /** O que fica entre o nome da tela e o do sistema. */
15
- const SEPARATOR = " · ";
18
+ /** O último título que este hook escreveu é como se reconhece o de fora. */
19
+ let applied = "";
16
20
 
17
21
  /**
18
22
  * A aba do navegador diz em que tela se está.
19
23
  *
20
24
  * Com cinco abas do sistema abertas, todas se chamavam só pelo nome dele — e
21
- * achar a do financeiro era clicar uma a uma. A tela vem **antes** do nome do
22
- * sistema, e não depois: a aba trunca pelo fim, e "ControlX - Controle
23
- * fina…" vira "ControlX" em todas.
25
+ * achar a do financeiro era clicar uma a uma. O sistema vem primeiro e a tela
26
+ * depois: é como se um caminho, e a aba do sistema continua reconhecível
27
+ * pelo começo, que é onde o olho procura.
28
+ *
29
+ * Escrever uma vez não basta, e é por isso que há um `MutationObserver`: o
30
+ * App Router reescreve o `<title>` do `metadata` **depois** do efeito — na
31
+ * hidratação e a cada navegação —, então o nome da tela aparecia e sumia no
32
+ * mesmo quadro. Entre uma rota e outra o `<head>` chega a ficar sem `<title>`
33
+ * nenhum; esse vazio não vira `baseTitle`, senão o nome do sistema se perdia
34
+ * e a aba passava a mostrar só a tela.
24
35
  *
25
36
  * `null` devolve o título de origem — é o que a tela sem migalha faz.
26
37
  */
27
38
  export const useDocumentTitle = (title: string | null): void => {
28
39
  useEffect(() => {
29
- if (baseTitle === null) {
30
- baseTitle = document.title;
31
- }
40
+ const apply = (): void => {
41
+ const current = document.title;
42
+
43
+ if (current && current !== applied) {
44
+ baseTitle = current;
45
+ }
46
+
47
+ const next =
48
+ title && baseTitle ? `${baseTitle}${SEPARATOR}${title}` : title || baseTitle;
49
+
50
+ // Sem reescrever o que já está lá: a escrita dispara o observador, e
51
+ // comparar aqui é o que impede o laço.
52
+ if (!next || next === current) {
53
+ return;
54
+ }
55
+
56
+ applied = next;
57
+ document.title = next;
58
+ };
59
+
60
+ apply();
32
61
 
33
- if (!title) {
34
- document.title = baseTitle;
35
- return;
36
- }
62
+ const observer = new MutationObserver(apply);
63
+ observer.observe(document.head, {
64
+ childList: true,
65
+ subtree: true,
66
+ characterData: true,
67
+ });
37
68
 
38
- document.title = baseTitle ? `${title}${SEPARATOR}${baseTitle}` : title;
69
+ return () => observer.disconnect();
39
70
  }, [title]);
40
71
  };
@@ -63,7 +63,9 @@ export function useFilterSchema(url: string): UseFilterSchemaResult {
63
63
  }, [url]);
64
64
 
65
65
  useEffect(() => {
66
- void load();
66
+ void (async (): Promise<void> => {
67
+ await load();
68
+ })();
67
69
  }, [load]);
68
70
 
69
71
  return { schema, loading };
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
 
5
5
  import type { AdvancedGroup, AdvancedValidation } from "#core/_utils/advanced-filter";
6
6
  import {
@@ -13,6 +13,7 @@ import {
13
13
  import type { FilterField, FilterValues } from "#core/_utils/filter";
14
14
  import { buildFilter, parseFilterValues } from "#core/_utils/filter";
15
15
  import { useFilterSchema } from "#core/hooks/use-filter-schema";
16
+ import { useIsHydrated } from "#core/hooks/use-is-hydrated";
16
17
 
17
18
  export type FilterMode = "simple" | "advanced";
18
19
 
@@ -83,13 +84,17 @@ export function useFilters(
83
84
  const [tree, setTreeState] = useState<AdvancedGroup | null>(null);
84
85
  const [hydrated, setHydrated] = useState(false);
85
86
 
87
+ const isClient = useIsHydrated();
88
+
86
89
  // Hidrata da URL uma vez, quando o catálogo chega: sem ele não dá para saber
87
90
  // o tipo de cada campo, e portanto nem como ler o JSON. O combinador no topo
88
91
  // do JSON é o que revela que o filtro veio do modo avançado.
89
- useEffect(() => {
90
- if (hydrated || loading) {
91
- return;
92
- }
92
+ //
93
+ // No render, e não num efeito: a listagem espera o `hydrated` para buscar, e
94
+ // por um efeito a espera custaria um render a mais em toda tela com filtro.
95
+ // A querystring só é lida com `isClient` — no servidor não há `window`.
96
+ if (isClient && !hydrated && !loading) {
97
+ setHydrated(true);
93
98
  const raw = new URLSearchParams(window.location.search).get(QUERY_PARAM);
94
99
  if (raw && isAdvancedFilter(raw)) {
95
100
  const parsed = parseAdvancedFilter(raw, schema);
@@ -100,13 +105,34 @@ export function useFilters(
100
105
  } else if (raw) {
101
106
  setValuesState(parseFilterValues(raw, schema));
102
107
  } else if (defaultValues) {
103
- // Espelha na URL como qualquer outro filtro: sem isso a tela mostraria
104
- // um estado que o link não carrega.
105
108
  setValuesState(defaultValues);
106
- writeToUrl(buildFilter(defaultValues, schema));
107
109
  }
108
- setHydrated(true);
109
- }, [hydrated, loading, schema, defaultValues]);
110
+ }
111
+
112
+ /**
113
+ * O padrão também vai para a URL: sem isso a tela mostraria um estado que o
114
+ * link não carrega.
115
+ *
116
+ * Escrever é efeito colateral, então mora num efeito — e só na primeira vez,
117
+ * guardado pelo `ref`: depois de hidratado, limpar os filtros deixa a URL sem
118
+ * `?filter=`, e repetir a escrita aqui faria o padrão voltar sozinho.
119
+ */
120
+ const defaultApplied = useRef(false);
121
+
122
+ useEffect(() => {
123
+ if (!hydrated || defaultApplied.current) {
124
+ return;
125
+ }
126
+ defaultApplied.current = true;
127
+
128
+ if (!defaultValues) {
129
+ return;
130
+ }
131
+ if (new URLSearchParams(window.location.search).get(QUERY_PARAM)) {
132
+ return;
133
+ }
134
+ writeToUrl(buildFilter(defaultValues, schema));
135
+ }, [hydrated, defaultValues, schema]);
110
136
 
111
137
  const setValues = useCallback(
112
138
  (next: FilterValues): void => {
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+
5
+ /** Nunca muda depois de hidratar, então não há a que se inscrever. */
6
+ const subscribeNever = (): (() => void) => () => {};
7
+
8
+ /**
9
+ * Já estamos no navegador, com o que só existe lá?
10
+ *
11
+ * Serve a quem precisa esperar a hidratação para agir — escrever na URL,
12
+ * aplicar classe no `<html>` — sem copiar nada para um estado dentro de um
13
+ * efeito. No servidor é `false`; no cliente vira `true` junto com o primeiro
14
+ * render que já enxerga `window`.
15
+ */
16
+ export const useIsHydrated = (): boolean =>
17
+ useSyncExternalStore(
18
+ subscribeNever,
19
+ () => true,
20
+ () => false,
21
+ );
@@ -98,10 +98,15 @@ export function useJobProgress<TSummary = unknown>(
98
98
  IDLE as UseJobProgressResult<TSummary>,
99
99
  );
100
100
 
101
- // Job novo começa do zero: sem isto a barra do anterior ficaria em 100%.
102
- useEffect(() => {
101
+ const [lastJobId, setLastJobId] = useState(jobId);
102
+
103
+ // Job novo começa do zero: sem isto a barra do anterior ficaria em 100%. O
104
+ // ajuste é no render, então nem por um quadro a barra do job anterior
105
+ // aparece sob o novo.
106
+ if (jobId !== lastJobId) {
107
+ setLastJobId(jobId);
103
108
  setResult(IDLE as UseJobProgressResult<TSummary>);
104
- }, [jobId]);
109
+ }
105
110
 
106
111
  const sync = useCallback(async (): Promise<void> => {
107
112
  if (!jobId) {
@@ -132,7 +137,9 @@ export function useJobProgress<TSummary = unknown>(
132
137
  }, [jobId]);
133
138
 
134
139
  useEffect(() => {
135
- void sync();
140
+ void (async (): Promise<void> => {
141
+ await sync();
142
+ })();
136
143
  }, [sync, reconnectCount]);
137
144
 
138
145
  useEffect(() => {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { SortingState } from "@tanstack/react-table";
4
4
  import type { Dispatch, SetStateAction } from "react";
5
- import { useEffect, useMemo, useState } from "react";
5
+ import { useMemo, useState } from "react";
6
6
 
7
7
  import type { ListQuery } from "#core/_services/api/list-query";
8
8
  import type { FilterValues } from "#core/_utils/filter";
@@ -50,11 +50,16 @@ export function useListQuery(
50
50
  const [pageSize, setPageSize] = useState(initialPageSize);
51
51
  const [sorting, setSorting] = useState<SortingState>([]);
52
52
 
53
+ const [lastFilter, setLastFilter] = useState(filter);
54
+
53
55
  // Trocar o filtro volta para a primeira página: a página 3 de um resultado
54
- // que agora tem uma página só voltaria vazia.
55
- useEffect(() => {
56
+ // que agora tem uma página só voltaria vazia. O ajuste é no render, então a
57
+ // busca já sai com a página certa — por um efeito, a primeira query iria com
58
+ // a página antiga e seria descartada.
59
+ if (filter !== lastFilter) {
60
+ setLastFilter(filter);
56
61
  setPage(0);
57
- }, [filter]);
62
+ }
58
63
 
59
64
  const sort = sorting[0];
60
65
  const query = useMemo<ListQuery>(
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { useCallback, useSyncExternalStore } from "react";
4
+
5
+ import { safeStorage } from "#core/_utils/storage";
6
+
7
+ const noop = (): void => {};
8
+
9
+ /**
10
+ * O que está guardado numa chave do armazenamento, lido no próprio render.
11
+ *
12
+ * A preferência salva não é estado da tela: é estado de fora dela, e copiá-la
13
+ * para um `useState` dentro de um efeito custava um render a mais e mostrava o
14
+ * padrão por um quadro. `useSyncExternalStore` resolve as duas pontas — no
15
+ * servidor devolve `null`, e no cliente o valor certo já no primeiro render
16
+ * depois da hidratação.
17
+ *
18
+ * Devolve a string crua de propósito: é o que compara por identidade sem
19
+ * truque. Quem guarda JSON faz o `parse` num `useMemo`, senão cada leitura
20
+ * seria um objeto novo e o React re-renderizaria para sempre.
21
+ */
22
+ export const useStoredValue = (key: string | undefined): string | null => {
23
+ const subscribe = useCallback(
24
+ (onChange: () => void): (() => void) =>
25
+ key ? safeStorage.subscribe(key, onChange) : noop,
26
+ [key],
27
+ );
28
+
29
+ const getSnapshot = useCallback(
30
+ (): string | null => (key ? safeStorage.get(key) : null),
31
+ [key],
32
+ );
33
+
34
+ return useSyncExternalStore(subscribe, getSnapshot, () => null);
35
+ };