@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.
- package/dist/{BlockNoteEditor-MCYQBKT5.js → BlockNoteEditor-BENYTTCZ.js} +19 -19
- package/dist/{BlockNoteEditor-MCYQBKT5.js.map → BlockNoteEditor-BENYTTCZ.js.map} +1 -1
- package/dist/{BlockNoteEditor-A2E4VUYB.mjs → BlockNoteEditor-VOYKZOP5.mjs} +4 -4
- package/dist/{BlockNoteViewer-TEC7DN2N.js → BlockNoteViewer-USLR4Y7C.js} +3 -3
- package/dist/{BlockNoteViewer-TEC7DN2N.js.map → BlockNoteViewer-USLR4Y7C.js.map} +1 -1
- package/dist/{BlockNoteViewer-4THJHQMK.mjs → BlockNoteViewer-Y2ZM4ESA.mjs} +2 -2
- package/dist/billing/index.js +399 -399
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-IOG22OAO.mjs → chunk-46S3KPLU.mjs} +2 -2
- package/dist/{chunk-5PNDDYGL.js → chunk-4BZJ4LAB.js} +16 -2
- package/dist/chunk-4BZJ4LAB.js.map +1 -0
- package/dist/{chunk-DJRTJBSZ.mjs → chunk-6ISF5KNQ.mjs} +18 -7
- package/dist/chunk-6ISF5KNQ.mjs.map +1 -0
- package/dist/{chunk-C2WQXUL5.js → chunk-F7RNBGHS.js} +3 -3
- package/dist/{chunk-C2WQXUL5.js.map → chunk-F7RNBGHS.js.map} +1 -1
- package/dist/{chunk-RDI62FAT.mjs → chunk-HZVVRIJQ.mjs} +2 -2
- package/dist/{chunk-DYTKNSHY.js → chunk-KS6WZP2E.js} +4 -4
- package/dist/{chunk-DYTKNSHY.js.map → chunk-KS6WZP2E.js.map} +1 -1
- package/dist/{chunk-PL2FHXHR.js → chunk-ORU7KLCY.js} +1068 -1057
- package/dist/chunk-ORU7KLCY.js.map +1 -0
- package/dist/{chunk-7ZRQOLSV.js → chunk-WQ77CUUZ.js} +7 -7
- package/dist/{chunk-7ZRQOLSV.js.map → chunk-WQ77CUUZ.js.map} +1 -1
- package/dist/{chunk-XTOEN5GN.mjs → chunk-ZO3QQ5CQ.mjs} +2 -2
- package/dist/{chunk-Z3226FM6.mjs → chunk-ZZ6E33FO.mjs} +16 -2
- package/dist/chunk-ZZ6E33FO.mjs.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/analytics/index.js +122 -122
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +125 -125
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/next-config/index.d.mts +8 -1
- package/dist/next-config/index.d.ts +8 -1
- package/dist/next-config/index.js +31 -9
- package/dist/next-config/index.js.map +1 -1
- package/dist/next-config/index.mjs +31 -9
- package/dist/next-config/index.mjs.map +1 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/package.json +1 -1
- package/src/features/company/components/details/CompanyContent.tsx +9 -2
- package/src/features/company/components/forms/CompanyEditor.tsx +2 -0
- package/src/next-config/__tests__/createNextConfig.test.ts +45 -3
- package/src/next-config/csp.ts +25 -4
- package/src/next-config/index.ts +19 -12
- package/src/utils/__tests__/use-mobile.test.tsx +111 -0
- package/src/utils/use-mobile.tsx +30 -1
- package/dist/chunk-5PNDDYGL.js.map +0 -1
- package/dist/chunk-DJRTJBSZ.mjs.map +0 -1
- package/dist/chunk-PL2FHXHR.js.map +0 -1
- package/dist/chunk-Z3226FM6.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-A2E4VUYB.mjs.map → BlockNoteEditor-VOYKZOP5.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-4THJHQMK.mjs.map → BlockNoteViewer-Y2ZM4ESA.mjs.map} +0 -0
- /package/dist/{chunk-IOG22OAO.mjs.map → chunk-46S3KPLU.mjs.map} +0 -0
- /package/dist/{chunk-RDI62FAT.mjs.map → chunk-HZVVRIJQ.mjs.map} +0 -0
- /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
|
|
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: [
|
|
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,}/);
|
package/src/next-config/csp.ts
CHANGED
|
@@ -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,
|
|
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
|
};
|
package/src/next-config/index.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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[] = [];
|
package/src/utils/use-mobile.tsx
CHANGED
|
@@ -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
|
-
|
|
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(() => {
|