@carlonicora/nextjs-jsonapi 4.0.8 → 4.1.1

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 (68) hide show
  1. package/dist/{BlockNoteEditor-MCYQBKT5.js → BlockNoteEditor-BENYTTCZ.js} +19 -19
  2. package/dist/{BlockNoteEditor-MCYQBKT5.js.map → BlockNoteEditor-BENYTTCZ.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-A2E4VUYB.mjs → BlockNoteEditor-VOYKZOP5.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-TEC7DN2N.js → BlockNoteViewer-USLR4Y7C.js} +3 -3
  5. package/dist/{BlockNoteViewer-TEC7DN2N.js.map → BlockNoteViewer-USLR4Y7C.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-4THJHQMK.mjs → BlockNoteViewer-Y2ZM4ESA.mjs} +2 -2
  7. package/dist/billing/index.js +399 -399
  8. package/dist/billing/index.mjs +3 -3
  9. package/dist/{chunk-IOG22OAO.mjs → chunk-46S3KPLU.mjs} +2 -2
  10. package/dist/{chunk-5PNDDYGL.js → chunk-4BZJ4LAB.js} +16 -2
  11. package/dist/chunk-4BZJ4LAB.js.map +1 -0
  12. package/dist/{chunk-DJRTJBSZ.mjs → chunk-6ISF5KNQ.mjs} +18 -7
  13. package/dist/chunk-6ISF5KNQ.mjs.map +1 -0
  14. package/dist/{chunk-C2WQXUL5.js → chunk-F7RNBGHS.js} +3 -3
  15. package/dist/{chunk-C2WQXUL5.js.map → chunk-F7RNBGHS.js.map} +1 -1
  16. package/dist/{chunk-RDI62FAT.mjs → chunk-HZVVRIJQ.mjs} +2 -2
  17. package/dist/{chunk-DYTKNSHY.js → chunk-KS6WZP2E.js} +4 -4
  18. package/dist/{chunk-DYTKNSHY.js.map → chunk-KS6WZP2E.js.map} +1 -1
  19. package/dist/{chunk-PL2FHXHR.js → chunk-ORU7KLCY.js} +1068 -1057
  20. package/dist/chunk-ORU7KLCY.js.map +1 -0
  21. package/dist/{chunk-7ZRQOLSV.js → chunk-WQ77CUUZ.js} +7 -7
  22. package/dist/{chunk-7ZRQOLSV.js.map → chunk-WQ77CUUZ.js.map} +1 -1
  23. package/dist/{chunk-XTOEN5GN.mjs → chunk-ZO3QQ5CQ.mjs} +2 -2
  24. package/dist/{chunk-Z3226FM6.mjs → chunk-ZZ6E33FO.mjs} +16 -2
  25. package/dist/chunk-ZZ6E33FO.mjs.map +1 -0
  26. package/dist/client/index.js +4 -4
  27. package/dist/client/index.mjs +3 -3
  28. package/dist/components/index.js +4 -4
  29. package/dist/components/index.mjs +3 -3
  30. package/dist/contexts/index.js +4 -4
  31. package/dist/contexts/index.mjs +3 -3
  32. package/dist/core/index.js +2 -2
  33. package/dist/core/index.mjs +1 -1
  34. package/dist/features/analytics/index.js +122 -122
  35. package/dist/features/analytics/index.mjs +4 -4
  36. package/dist/features/analytics/server.js +4 -4
  37. package/dist/features/analytics/server.mjs +2 -2
  38. package/dist/features/help/index.js +37 -37
  39. package/dist/features/help/index.mjs +3 -3
  40. package/dist/features/tokenusage/index.js +125 -125
  41. package/dist/features/tokenusage/index.mjs +4 -4
  42. package/dist/index.js +3 -3
  43. package/dist/index.mjs +2 -2
  44. package/dist/next-config/index.d.mts +8 -1
  45. package/dist/next-config/index.d.ts +8 -1
  46. package/dist/next-config/index.js +31 -9
  47. package/dist/next-config/index.js.map +1 -1
  48. package/dist/next-config/index.mjs +31 -9
  49. package/dist/next-config/index.mjs.map +1 -1
  50. package/dist/server/index.js +3 -3
  51. package/dist/server/index.mjs +2 -2
  52. package/package.json +1 -1
  53. package/src/features/company/components/details/CompanyContent.tsx +9 -2
  54. package/src/features/company/components/forms/CompanyEditor.tsx +2 -0
  55. package/src/next-config/__tests__/createNextConfig.test.ts +45 -3
  56. package/src/next-config/csp.ts +25 -4
  57. package/src/next-config/index.ts +19 -12
  58. package/src/utils/__tests__/use-mobile.test.tsx +111 -0
  59. package/src/utils/use-mobile.tsx +30 -1
  60. package/dist/chunk-5PNDDYGL.js.map +0 -1
  61. package/dist/chunk-DJRTJBSZ.mjs.map +0 -1
  62. package/dist/chunk-PL2FHXHR.js.map +0 -1
  63. package/dist/chunk-Z3226FM6.mjs.map +0 -1
  64. /package/dist/{BlockNoteEditor-A2E4VUYB.mjs.map → BlockNoteEditor-VOYKZOP5.mjs.map} +0 -0
  65. /package/dist/{BlockNoteViewer-4THJHQMK.mjs.map → BlockNoteViewer-Y2ZM4ESA.mjs.map} +0 -0
  66. /package/dist/{chunk-IOG22OAO.mjs.map → chunk-46S3KPLU.mjs.map} +0 -0
  67. /package/dist/{chunk-RDI62FAT.mjs.map → chunk-HZVVRIJQ.mjs.map} +0 -0
  68. /package/dist/{chunk-XTOEN5GN.mjs.map → chunk-ZO3QQ5CQ.mjs.map} +0 -0
@@ -7,7 +7,7 @@ import { ReactNode } from "react";
7
7
  import { AttributeElement } from "../../../../components";
8
8
  import { SectionHeader, MicroLabel } from "../../../../components/typography";
9
9
  import { FiscalDataDisplay } from "../../../../components/fiscal/FiscalDataDisplay";
10
- import { cn } from "../../../../utils";
10
+ import { cn, isRemoteImageSrc } from "../../../../utils";
11
11
  import { CompanyInterface } from "../../data";
12
12
 
13
13
  type CompanyContentProps = {
@@ -44,7 +44,14 @@ export function CompanyContent({ company, actions }: CompanyContentProps) {
44
44
  {/* Hero Section */}
45
45
  <div className="flex items-start gap-x-6">
46
46
  {company.logo && (
47
- <Image src={company.logo} alt={company.name} width={150} height={150} className="rounded-md" />
47
+ <Image
48
+ src={company.logo}
49
+ alt={company.name}
50
+ width={150}
51
+ height={150}
52
+ className="rounded-md"
53
+ unoptimized={isRemoteImageSrc(company.logo)}
54
+ />
48
55
  )}
49
56
  <div className="flex flex-col gap-y-2">
50
57
  {company.legal_address && (
@@ -26,6 +26,7 @@ import { Modules } from "../../../../core";
26
26
  import { useI18nRouter } from "../../../../i18n";
27
27
  import { getRoleId } from "../../../../roles";
28
28
  import { ScrollArea } from "../../../../shadcnui";
29
+ import { isRemoteImageSrc } from "../../../../utils";
29
30
  import { FeatureInterface } from "../../../feature";
30
31
  import { FeatureService } from "../../../feature/data/feature.service";
31
32
  import { S3Interface } from "../../../s3";
@@ -232,6 +233,7 @@ function CompanyEditorInternal({
232
233
  alt="Company Logo"
233
234
  width={200}
234
235
  height={200}
236
+ unoptimized={isRemoteImageSrc(file ? undefined : company?.logo)}
235
237
  />
236
238
  ) : (
237
239
  <>
@@ -9,12 +9,12 @@ const FIXED_ENV = vi.hoisted(() => {
9
9
  NEXT_PUBLIC_API_URL: "https://api.test",
10
10
  NEXT_PUBLIC_ADDRESS: "https://app.test",
11
11
  IMAGE_SOURCES: "https://img.test",
12
+ NEXT_PUBLIC_CORPUS_URL: "https://corpus.test",
12
13
  APP_URL: "https://app.test",
13
14
  };
14
15
  for (const key of [
15
16
  "NEXT_PUBLIC_VAPID_PUBLIC_KEY",
16
17
  "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY",
17
- "NEXT_PUBLIC_CORPUS_URL",
18
18
  "NEXT_PUBLIC_GTM_ID",
19
19
  "E2E_BUILD",
20
20
  ])
@@ -139,7 +139,11 @@ describe("createNextConfig: behaviour", () => {
139
139
  expect(plain.allowedDevOrigins).toEqual(["example.test"]);
140
140
  expect(plain.images).toEqual({
141
141
  minimumCacheTTL: 60,
142
- remotePatterns: [{ protocol: "https", hostname: "img.test" }],
142
+ remotePatterns: [
143
+ { protocol: "https", hostname: "img.test" },
144
+ { protocol: "https", hostname: "api.test" },
145
+ { protocol: "https", hostname: "corpus.test" },
146
+ ],
143
147
  dangerouslyAllowLocalIP: true,
144
148
  });
145
149
 
@@ -166,6 +170,16 @@ describe("createNextConfig: behaviour", () => {
166
170
  expect(production.distDir).toBeUndefined();
167
171
  });
168
172
 
173
+ it("lets the optimizer fetch from the api and corpus origins, skipping unset and duplicate ones", () => {
174
+ vi.stubEnv("NEXT_PUBLIC_CORPUS_URL", undefined);
175
+ vi.stubEnv("IMAGE_SOURCES", "https://img.test,https://api.test");
176
+ const config = createNextConfig(base) as { images: { remotePatterns: unknown[] } };
177
+ expect(config.images.remotePatterns).toEqual([
178
+ { protocol: "https", hostname: "img.test" },
179
+ { protocol: "https", hostname: "api.test" },
180
+ ]);
181
+ });
182
+
169
183
  it("reactCompiler option sets both reactCompiler and turbopackRustReactCompiler", () => {
170
184
  const on = createNextConfig({ ...base, reactCompiler: true });
171
185
  expect(on.reactCompiler).toBe(true);
@@ -197,7 +211,7 @@ describe("buildCsp", () => {
197
211
  expect(csp["script-src"]).toEqual(["'self'", "'unsafe-eval'", "'unsafe-inline'"]);
198
212
  expect(csp["style-src"]).toEqual(["'self'", "'unsafe-inline'"]);
199
213
  expect(csp["font-src"]).toEqual(["'self'"]);
200
- expect(csp["frame-src"]).toEqual(["'self'"]);
214
+ expect(csp["frame-src"]).toEqual(["'self'", "https://api.test", "https://corpus.test"]);
201
215
  expect(csp["worker-src"]).toEqual(["'self'", "blob:"]);
202
216
  expect(buildCsp({}, env)).not.toMatch(/stripe|google|cloudflare|discord/);
203
217
  });
@@ -246,6 +260,8 @@ describe("buildCsp", () => {
246
260
  const csp = parseCsp(buildCsp({ services: ["stripe"], csp: { "frame-src": ["https://docs.google.com/"] } }, env));
247
261
  expect(csp["frame-src"]).toEqual([
248
262
  "'self'",
263
+ "https://api.test",
264
+ "https://corpus.test",
249
265
  "https://docs.google.com/",
250
266
  "https://hooks.stripe.com",
251
267
  "https://js.stripe.com",
@@ -260,6 +276,32 @@ describe("buildCsp", () => {
260
276
  expect(csp["img-src"]).toContain("https://img.test");
261
277
  });
262
278
 
279
+ it("allows images and frames from the api and corpus origins", () => {
280
+ const csp = parseCsp(buildCsp({}, env));
281
+ for (const directive of ["img-src", "frame-src"]) {
282
+ expect(csp[directive]).toEqual(expect.arrayContaining(["https://api.test", "https://corpus.test"]));
283
+ }
284
+ });
285
+
286
+ it("lists the api and corpus origins once, and nothing for an unset corpus", () => {
287
+ const value = buildCsp({}, {
288
+ NODE_ENV: "production",
289
+ NEXT_PUBLIC_API_URL: "https://api.test/",
290
+ IMAGE_SOURCES: "https://api.test",
291
+ } as unknown as NodeJS.ProcessEnv);
292
+ for (const directive of ["img-src", "frame-src"]) {
293
+ const sources = value
294
+ .split(";")
295
+ .map((part) => part.trim().split(/\s+/))
296
+ .find(([name]) => name === directive)!
297
+ .slice(1);
298
+ expect(sources.filter((source) => source === "https://api.test")).toHaveLength(1);
299
+ expect(sources.join(" ")).not.toContain("corpus");
300
+ expect(sources).not.toContain("");
301
+ }
302
+ expect(value).not.toMatch(/\s{2,}/);
303
+ });
304
+
263
305
  it("drops empty sources when env values are missing", () => {
264
306
  const value = buildCsp({ csp: { "img-src": [""] } }, { NODE_ENV: "development" } as unknown as NodeJS.ProcessEnv);
265
307
  expect(value).not.toMatch(/\s{2,}/);
@@ -52,12 +52,33 @@ function listImageSources(env: NodeJS.ProcessEnv): string[] {
52
52
  return env.IMAGE_SOURCES ? env.IMAGE_SOURCES.split(",").map((s) => s.trim()) : [];
53
53
  }
54
54
 
55
+ /**
56
+ * Origins of the app's own servers, the API and the corpus, from
57
+ * `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_CORPUS_URL`. They serve every stored
58
+ * object link, so images and frames must be allowed from them. Unset or invalid
59
+ * URLs are skipped; the result has no duplicates.
60
+ */
61
+ export function listAppOrigins(env: NodeJS.ProcessEnv): string[] {
62
+ const origins: string[] = [];
63
+ for (const url of [env.NEXT_PUBLIC_API_URL, env.NEXT_PUBLIC_CORPUS_URL]) {
64
+ if (!url) continue;
65
+ try {
66
+ const { origin } = new URL(url.trim());
67
+ if (origin !== "null" && !origins.includes(origin)) origins.push(origin);
68
+ } catch {
69
+ // Not a URL: nothing to allow.
70
+ }
71
+ }
72
+ return origins;
73
+ }
74
+
55
75
  // Build CSP directives
56
76
  // Note: 'unsafe-inline' and 'unsafe-eval' needed for Next.js dev mode and some runtime features
57
77
  // In production, consider using nonces for stricter CSP
58
78
  export function buildCsp(options: CspOptions, env: NodeJS.ProcessEnv): string {
59
79
  const apiUrl = env.NEXT_PUBLIC_API_URL || "";
60
80
  const imageSources = listImageSources(env);
81
+ const appOrigins = listAppOrigins(env);
61
82
  const services = options.services ?? [];
62
83
 
63
84
  // Each fetch directive: what Next itself needs, then the named services, then
@@ -67,8 +88,8 @@ export function buildCsp(options: CspOptions, env: NodeJS.ProcessEnv): string {
67
88
  "script-src": { base: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], fromEnv: [] },
68
89
  // Styles: self and unsafe-inline for styled-components/emotion
69
90
  "style-src": { base: ["'self'", "'unsafe-inline'"], fromEnv: [] },
70
- // Images: self, data URIs, blobs, and configured image sources
71
- "img-src": { base: ["'self'", "data:", "blob:"], fromEnv: imageSources },
91
+ // Images: self, data URIs, blobs, configured image sources, and the api/corpus origins
92
+ "img-src": { base: ["'self'", "data:", "blob:"], fromEnv: [...imageSources, ...appOrigins] },
72
93
  // Fonts: self
73
94
  "font-src": { base: ["'self'"], fromEnv: [] },
74
95
  // Connect: self, API (http + ws for socket.io), and storage (for uploads)
@@ -82,8 +103,8 @@ export function buildCsp(options: CspOptions, env: NodeJS.ProcessEnv): string {
82
103
  ...imageSources,
83
104
  ],
84
105
  },
85
- // Frames: self
86
- "frame-src": { base: ["'self'"], fromEnv: [] },
106
+ // Frames: self, and the api/corpus origins (inline previews of stored objects)
107
+ "frame-src": { base: ["'self'"], fromEnv: appOrigins },
87
108
  // Worker sources: self and blob (for Web Workers with dynamic imports)
88
109
  "worker-src": { base: ["'self'", "blob:"], fromEnv: [] },
89
110
  };
@@ -6,10 +6,10 @@
6
6
  // runtime: never next-intl, @next/mdx, serwist or any devDependency. `next` is
7
7
  // imported for its types only.
8
8
  import type { NextConfig } from "next";
9
- import { buildCsp, type CspDirective, type CspService } from "./csp";
9
+ import { buildCsp, listAppOrigins, type CspDirective, type CspService } from "./csp";
10
10
  import { buildSecurityHeaders } from "./headers";
11
11
 
12
- export { buildCsp };
12
+ export { buildCsp, listAppOrigins };
13
13
  export type { CspDirective, CspService };
14
14
 
15
15
  export interface NjaNextConfigOptions {
@@ -32,17 +32,24 @@ export interface NjaNextConfigOptions {
32
32
 
33
33
  type RemotePattern = { protocol: "http" | "https"; hostname: string; port?: string };
34
34
 
35
+ // IMAGE_SOURCES, then the api and corpus origins (they serve every stored object
36
+ // link), without duplicates.
35
37
  function parseImageSources(env: NodeJS.ProcessEnv): RemotePattern[] {
36
- return env.IMAGE_SOURCES
37
- ? env.IMAGE_SOURCES.split(",").map((url) => {
38
- const { hostname, protocol, port } = new URL(url.trim());
39
- return {
40
- protocol: protocol.replace(":", "") as RemotePattern["protocol"],
41
- hostname,
42
- port: port || undefined,
43
- };
44
- })
45
- : [];
38
+ const imageSources = env.IMAGE_SOURCES ? env.IMAGE_SOURCES.split(",").map((url) => url.trim()) : [];
39
+ const patterns: RemotePattern[] = [];
40
+ for (const url of [...imageSources, ...listAppOrigins(env)]) {
41
+ const { hostname, protocol, port } = new URL(url);
42
+ const pattern: RemotePattern = {
43
+ protocol: protocol.replace(":", "") as RemotePattern["protocol"],
44
+ hostname,
45
+ port: port || undefined,
46
+ };
47
+ const seen = patterns.some(
48
+ (p) => p.protocol === pattern.protocol && p.hostname === pattern.hostname && p.port === pattern.port,
49
+ );
50
+ if (!seen) patterns.push(pattern);
51
+ }
52
+ return patterns;
46
53
  }
47
54
 
48
55
  export function createNextConfig(options: NjaNextConfigOptions): NextConfig {
@@ -1,3 +1,4 @@
1
+ import * as React from "react";
1
2
  import { Suspense } from "react";
2
3
  import { render, screen, act } from "@testing-library/react";
3
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -219,6 +220,116 @@ describe("ViewportProvider with a session promise", () => {
219
220
  expect(screen.getByTestId("probe").textContent).toBe("mobile");
220
221
  });
221
222
 
223
+ it("calls use() on every render when a pending seed is replaced by a boolean while the reader is suspended", async () => {
224
+ // The sequence behind the "conditional-use-of-use" console error seen on
225
+ // /deliveries/<id> and on (admin) not-found pages:
226
+ // 1. a reader mounts while ViewportProvider's value is still the session
227
+ // promise, so useIsMobile() calls use() and suspends;
228
+ // 2. the provider's mount-time measurement replaces the promise with a
229
+ // boolean (setIsMobile) before the session settles;
230
+ // 3. the reader retries with a plain boolean. Skipping use() on that render
231
+ // makes React log "This library called use() to suspend in a previous
232
+ // render but did not call use() when it finished".
233
+ // The provider's state swap is driven by hand here so that step 2 lands
234
+ // after the suspended mount, as it does when hydration wins the race.
235
+ const ViewportContext = getViewportContext();
236
+ let swap!: (value: boolean | Promise<boolean>) => void;
237
+ function SwappingProvider({ children }: { children: React.ReactNode }) {
238
+ const [value, setValue] = React.useState<boolean | Promise<boolean>>(() => deferred<boolean>().promise);
239
+ swap = setValue;
240
+ return <ViewportContext.Provider value={value}>{children}</ViewportContext.Provider>;
241
+ }
242
+
243
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
244
+ try {
245
+ await act(async () => {
246
+ render(
247
+ <SwappingProvider>
248
+ <Suspense fallback="waiting">
249
+ <Probe />
250
+ </Suspense>
251
+ </SwappingProvider>,
252
+ );
253
+ });
254
+ expect(screen.queryByText("waiting")).not.toBeNull();
255
+
256
+ await act(async () => {
257
+ swap(true);
258
+ });
259
+
260
+ expect(screen.getByTestId("probe").textContent).toBe("mobile");
261
+ expect(errorSpy).not.toHaveBeenCalled();
262
+ } finally {
263
+ errorSpy.mockRestore();
264
+ }
265
+ });
266
+
267
+ it("lets the provider's measurement win over a session seed that settles later, without console errors", async () => {
268
+ setWidth(500);
269
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
270
+ try {
271
+ const pending = deferred<SessionSnapshot>();
272
+ await act(async () => {
273
+ render(
274
+ <ViewportProvider session={pending.promise}>
275
+ <Suspense fallback="waiting">
276
+ <Probe />
277
+ </Suspense>
278
+ </ViewportProvider>,
279
+ );
280
+ });
281
+ expect(screen.getByTestId("probe").textContent).toBe("mobile");
282
+
283
+ await act(async () => {
284
+ pending.resolve(snapshot(false));
285
+ await pending.promise;
286
+ });
287
+
288
+ expect(screen.getByTestId("probe").textContent).toBe("mobile");
289
+ expect(errorSpy).not.toHaveBeenCalled();
290
+ } finally {
291
+ errorSpy.mockRestore();
292
+ }
293
+ });
294
+
295
+ it("keeps calling use() when the context value goes from a boolean to a promise", async () => {
296
+ // The reverse switch: a reader mounted on a boolean still suspends on, and
297
+ // then reads, a promise that replaces it.
298
+ setWidth(1280);
299
+ const ViewportContext = getViewportContext();
300
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
301
+ try {
302
+ const pending = deferred<boolean>();
303
+ const { rerender } = render(
304
+ <ViewportContext.Provider value={false}>
305
+ <Suspense fallback="waiting">
306
+ <Probe />
307
+ </Suspense>
308
+ </ViewportContext.Provider>,
309
+ );
310
+ expect(screen.getByTestId("probe").textContent).toBe("desktop");
311
+
312
+ await act(async () => {
313
+ rerender(
314
+ <ViewportContext.Provider value={pending.promise}>
315
+ <Suspense fallback="waiting">
316
+ <Probe />
317
+ </Suspense>
318
+ </ViewportContext.Provider>,
319
+ );
320
+ });
321
+ await act(async () => {
322
+ pending.resolve(true);
323
+ await pending.promise;
324
+ });
325
+
326
+ expect(screen.getByTestId("probe").textContent).toBe("mobile");
327
+ expect(errorSpy).not.toHaveBeenCalled();
328
+ } finally {
329
+ errorSpy.mockRestore();
330
+ }
331
+ });
332
+
222
333
  it("ViewportProvider with initialIsMobile is unchanged", () => {
223
334
  setWidth(1280);
224
335
  const seen: string[] = [];
@@ -70,11 +70,40 @@ export function subscribeViewport(onChange: () => void): () => void {
70
70
  return () => mql.removeEventListener("change", onChange);
71
71
  }
72
72
 
73
+ type SettledThenable<T> = Promise<T> & { status: "fulfilled"; value: T };
74
+
75
+ /**
76
+ * A promise React reads synchronously: `use()` sees `status: "fulfilled"` and
77
+ * returns `value` without suspending. Created lazily, one per value, so the
78
+ * identity is stable across renders.
79
+ */
80
+ const settledViewportValues = new Map<boolean | undefined, SettledThenable<boolean | undefined>>();
81
+
82
+ function settled(value: boolean | undefined): SettledThenable<boolean | undefined> {
83
+ let thenable = settledViewportValues.get(value);
84
+ if (thenable === undefined) {
85
+ thenable = Object.assign(Promise.resolve(value), { status: "fulfilled" as const, value });
86
+ settledViewportValues.set(value, thenable);
87
+ }
88
+ return thenable;
89
+ }
90
+
91
+ function isThenable(value: ViewportContextValue): value is Promise<boolean> {
92
+ return typeof value === "object" && value !== null && typeof (value as PromiseLike<boolean>).then === "function";
93
+ }
94
+
73
95
  export function useIsMobile(): boolean {
74
96
  const provided = React.useContext(getViewportContext());
75
97
  // `React.use` through the namespace import, for the same reason as
76
98
  // `createContext` above. Suspends the caller until a session seed settles.
77
- const seeded = provided instanceof Promise ? React.use(provided) : provided;
99
+ //
100
+ // `use` is called on EVERY render, never only when the value is a promise. A
101
+ // session-seeded ViewportProvider exposes a promise first and a boolean once
102
+ // its mount-time measurement lands, which can happen while a reader is
103
+ // suspended on the promise. Skipping `use` on the retry makes React report
104
+ // https://react.dev/warnings/conditional-use-of-use. A plain value is wrapped
105
+ // in an already-settled thenable, which `use` returns without suspending.
106
+ const seeded = React.use<boolean | undefined>(isThenable(provided) ? provided : settled(provided));
78
107
  const [legacy, setLegacy] = React.useState<boolean>(false);
79
108
 
80
109
  React.useEffect(() => {