@carlonicora/nextjs-jsonapi 1.133.0 → 1.134.0
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-H626BS5Y.js → BlockNoteEditor-2WOKHBLM.js} +19 -19
- package/dist/{BlockNoteEditor-H626BS5Y.js.map → BlockNoteEditor-2WOKHBLM.js.map} +1 -1
- package/dist/{BlockNoteEditor-VXK7INMR.mjs → BlockNoteEditor-3IHMNBZ2.mjs} +4 -4
- package/dist/billing/index.js +357 -357
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-F6KBHC2X.js → chunk-C2LEIBUK.js} +7 -7
- package/dist/{chunk-F6KBHC2X.js.map → chunk-C2LEIBUK.js.map} +1 -1
- package/dist/{chunk-WEDLGKSS.mjs → chunk-DV5YYI3G.mjs} +1990 -1906
- package/dist/chunk-DV5YYI3G.mjs.map +1 -0
- package/dist/{chunk-DZL2G7NB.mjs → chunk-DVOBOFCF.mjs} +38 -11
- package/dist/chunk-DVOBOFCF.mjs.map +1 -0
- package/dist/{chunk-TVJFCWST.js → chunk-LBXHYRZH.js} +813 -729
- package/dist/chunk-LBXHYRZH.js.map +1 -0
- package/dist/{chunk-VSAAVVHY.mjs → chunk-M5IAN7IR.mjs} +2 -2
- package/dist/{chunk-5IE6DZ2D.js → chunk-W73JBOIR.js} +39 -12
- package/dist/{chunk-5IE6DZ2D.js.map → chunk-W73JBOIR.js.map} +1 -1
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +5 -1
- package/dist/components/index.d.ts +5 -1
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.d.mts +42 -2
- package/dist/contexts/index.d.ts +42 -2
- package/dist/contexts/index.js +12 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +11 -3
- package/dist/core/index.d.mts +3 -1
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +6 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +5 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/containers/PageContainer.tsx +7 -2
- package/src/components/containers/RoundPageContainer.tsx +16 -4
- package/src/components/navigations/Header.tsx +17 -6
- package/src/components/pages/PageContentContainer.tsx +1 -1
- package/src/contexts/HeaderChildrenContext.tsx +23 -2
- package/src/contexts/HeaderLogoContext.tsx +25 -0
- package/src/contexts/ViewportContext.tsx +46 -0
- package/src/contexts/index.ts +2 -0
- package/src/core/registry/__tests__/viewportStore.test.ts +50 -0
- package/src/core/registry/viewportStore.ts +47 -0
- package/src/utils/__tests__/use-mobile.test.tsx +137 -0
- package/src/utils/index.ts +1 -1
- package/src/utils/use-mobile.tsx +70 -13
- package/dist/chunk-DZL2G7NB.mjs.map +0 -1
- package/dist/chunk-TVJFCWST.js.map +0 -1
- package/dist/chunk-WEDLGKSS.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-VXK7INMR.mjs.map → BlockNoteEditor-3IHMNBZ2.mjs.map} +0 -0
- /package/dist/{chunk-VSAAVVHY.mjs.map → chunk-M5IAN7IR.mjs.map} +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useHeaderChildren } from "../../contexts/HeaderChildrenContext";
|
|
3
|
+
import { useHeaderChildren, useHeaderMobileChildren } from "../../contexts/HeaderChildrenContext";
|
|
4
4
|
import { useHeaderLeftContent } from "../../contexts/HeaderLeftContentContext";
|
|
5
|
+
import { useHeaderLogo } from "../../contexts/HeaderLogoContext";
|
|
5
6
|
import { cn } from "../../utils";
|
|
6
7
|
import { Header } from "../navigations";
|
|
7
8
|
|
|
@@ -9,11 +10,15 @@ type PageContainerProps = { children: React.ReactNode; testId?: string; classNam
|
|
|
9
10
|
|
|
10
11
|
export function PageContainer({ children, testId, className }: PageContainerProps) {
|
|
11
12
|
const headerChildren = useHeaderChildren();
|
|
13
|
+
const headerMobileChildren = useHeaderMobileChildren();
|
|
12
14
|
const headerLeftContent = useHeaderLeftContent();
|
|
15
|
+
const headerLogo = useHeaderLogo();
|
|
13
16
|
|
|
14
17
|
return (
|
|
15
18
|
<div className={`flex h-full w-full flex-col`} data-testid={testId}>
|
|
16
|
-
<Header leftContent={headerLeftContent}
|
|
19
|
+
<Header leftContent={headerLeftContent} logo={headerLogo} mobileChildren={headerMobileChildren}>
|
|
20
|
+
{headerChildren}
|
|
21
|
+
</Header>
|
|
17
22
|
<main className={cn(`flex w-full flex-1 flex-col gap-y-4 pt-4 pl-4 pr-0`, className)}>{children}</main>
|
|
18
23
|
</div>
|
|
19
24
|
);
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import { partitionTabs, Tab } from "@/components/containers";
|
|
21
21
|
import { RoundPageContainerTitle } from "@/components/containers/RoundPageContainerTitle";
|
|
22
22
|
import { Header } from "@/components/navigations";
|
|
23
|
-
import { useHeaderChildren, useHeaderLeftContent } from "@/contexts";
|
|
23
|
+
import { useHeaderChildren, useHeaderLeftContent, useHeaderLogo, useHeaderMobileChildren } from "@/contexts";
|
|
24
24
|
import { useUrlRewriter } from "@/hooks";
|
|
25
25
|
import { cn, useIsMobile } from "@/index";
|
|
26
26
|
import { ModuleWithPermissions } from "@/permissions";
|
|
@@ -85,6 +85,8 @@ export function RoundPageContainer({
|
|
|
85
85
|
}: RoundPageContainerProps) {
|
|
86
86
|
const headerChildren = useHeaderChildren();
|
|
87
87
|
const headerLeftContent = useHeaderLeftContent();
|
|
88
|
+
const headerLogo = useHeaderLogo();
|
|
89
|
+
const headerMobileChildren = useHeaderMobileChildren();
|
|
88
90
|
const [showDetails, setShowDetailsState] = useState(false);
|
|
89
91
|
const isMobile = useIsMobile();
|
|
90
92
|
const [mounted, setMounted] = useState(false);
|
|
@@ -148,12 +150,17 @@ export function RoundPageContainer({
|
|
|
148
150
|
[tabs],
|
|
149
151
|
);
|
|
150
152
|
|
|
151
|
-
const isReady = mounted
|
|
153
|
+
const isReady = mounted;
|
|
152
154
|
|
|
153
155
|
if (!isReady) {
|
|
154
156
|
return (
|
|
155
157
|
<>
|
|
156
|
-
<Header
|
|
158
|
+
<Header
|
|
159
|
+
leftContent={headerLeftContent}
|
|
160
|
+
logo={headerLogo}
|
|
161
|
+
mobileChildren={headerMobileChildren}
|
|
162
|
+
className="bg-sidebar border-0"
|
|
163
|
+
>
|
|
157
164
|
{headerChildren}
|
|
158
165
|
</Header>
|
|
159
166
|
<div className={cn("flex h-[calc(100vh-3rem)] w-full flex-col", isMobile ? "" : "p-2 pt-0 pl-0")}>
|
|
@@ -167,7 +174,12 @@ export function RoundPageContainer({
|
|
|
167
174
|
|
|
168
175
|
return (
|
|
169
176
|
<>
|
|
170
|
-
<Header
|
|
177
|
+
<Header
|
|
178
|
+
leftContent={headerLeftContent}
|
|
179
|
+
logo={headerLogo}
|
|
180
|
+
mobileChildren={headerMobileChildren}
|
|
181
|
+
className="bg-sidebar border-0"
|
|
182
|
+
>
|
|
171
183
|
{headerChildren}
|
|
172
184
|
</Header>
|
|
173
185
|
<div className={cn(`flex h-[calc(100vh-3rem)] w-full flex-col`, isMobile ? "p-1 pt-0" : "p-2 pt-0 pl-0")}>
|
|
@@ -8,11 +8,15 @@ import { BreadcrumbNavigation } from "./Breadcrumb";
|
|
|
8
8
|
|
|
9
9
|
type HeaderProps = {
|
|
10
10
|
children?: React.ReactNode;
|
|
11
|
+
/** Widgets kept on mobile, where there is no room for the full `children` set. */
|
|
12
|
+
mobileChildren?: React.ReactNode;
|
|
11
13
|
leftContent?: React.ReactNode;
|
|
14
|
+
/** Rendered before everything else, on mobile only — where the sidebar (and its logo) is off-canvas. */
|
|
15
|
+
logo?: React.ReactNode;
|
|
12
16
|
className?: string;
|
|
13
17
|
};
|
|
14
18
|
|
|
15
|
-
export function Header({ children, leftContent, className }: HeaderProps) {
|
|
19
|
+
export function Header({ children, mobileChildren, leftContent, logo, className }: HeaderProps) {
|
|
16
20
|
const { breadcrumbs } = useSharedContext();
|
|
17
21
|
const rootLabel = useHeaderRootLabel();
|
|
18
22
|
const isMobile = useIsMobile();
|
|
@@ -20,16 +24,23 @@ export function Header({ children, leftContent, className }: HeaderProps) {
|
|
|
20
24
|
return (
|
|
21
25
|
<header className={`sticky top-0 z-10 flex h-12 flex-col items-center justify-start gap-x-4 ${className ?? ""}`}>
|
|
22
26
|
<div className="bg-sidebar flex h-12 w-full flex-row items-center justify-between pl-2 pr-4">
|
|
27
|
+
{isMobile && logo && <div className="flex shrink-0 flex-row items-center pr-1">{logo}</div>}
|
|
23
28
|
<SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />
|
|
24
29
|
{leftContent}
|
|
25
30
|
<div className="flex w-full flex-row items-center justify-start">
|
|
26
31
|
<BreadcrumbNavigation items={breadcrumbs} rootLabel={rootLabel ?? undefined} />
|
|
27
32
|
</div>
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
{isMobile
|
|
34
|
+
? mobileChildren && (
|
|
35
|
+
<div className="flex shrink-0 flex-row items-center justify-end gap-x-2 whitespace-nowrap">
|
|
36
|
+
{mobileChildren}
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
: children && (
|
|
40
|
+
<div className="flex w-64 flex-row items-center justify-end gap-x-4 whitespace-nowrap">
|
|
41
|
+
<div className="flex flex-row items-center justify-end gap-x-4 whitespace-nowrap">{children}</div>
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
33
44
|
</div>
|
|
34
45
|
</header>
|
|
35
46
|
);
|
|
@@ -21,7 +21,7 @@ export function PageContentContainer({ header, details, footer, content, fullBle
|
|
|
21
21
|
setMounted(true);
|
|
22
22
|
}, []);
|
|
23
23
|
|
|
24
|
-
const isReady = mounted
|
|
24
|
+
const isReady = mounted;
|
|
25
25
|
|
|
26
26
|
// Create layout ID based on device type for separate persistence
|
|
27
27
|
const layoutId = `page-content-container-${isMobile ? "mobile" : "desktop"}`;
|
|
@@ -4,17 +4,20 @@ import { createContext, useContext, ReactNode } from "react";
|
|
|
4
4
|
|
|
5
5
|
interface HeaderChildrenContextType {
|
|
6
6
|
headerChildren: ReactNode | null;
|
|
7
|
+
headerMobileChildren: ReactNode | null;
|
|
7
8
|
headerRootLabel: string | null;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
const HeaderChildrenContext = createContext<HeaderChildrenContextType>({
|
|
11
12
|
headerChildren: null,
|
|
13
|
+
headerMobileChildren: null,
|
|
12
14
|
headerRootLabel: null,
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
interface HeaderChildrenProviderProps {
|
|
16
18
|
children: ReactNode;
|
|
17
19
|
content: ReactNode;
|
|
20
|
+
mobileContent?: ReactNode;
|
|
18
21
|
rootLabel?: string;
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -22,10 +25,19 @@ interface HeaderChildrenProviderProps {
|
|
|
22
25
|
* Provider to supply custom content to be rendered in the Header component.
|
|
23
26
|
* Wrap your layout with this provider and pass the content you want in the header.
|
|
24
27
|
* Optionally pass `rootLabel` to override the first breadcrumb entry (defaults to `common.home`).
|
|
28
|
+
*
|
|
29
|
+
* The header has far less room on mobile, so `content` is desktop-only. Pass `mobileContent`
|
|
30
|
+
* to choose which widgets survive there; omit it and the header stays bare on mobile.
|
|
25
31
|
*/
|
|
26
|
-
export function HeaderChildrenProvider({ children, content, rootLabel }: HeaderChildrenProviderProps) {
|
|
32
|
+
export function HeaderChildrenProvider({ children, content, mobileContent, rootLabel }: HeaderChildrenProviderProps) {
|
|
27
33
|
return (
|
|
28
|
-
<HeaderChildrenContext.Provider
|
|
34
|
+
<HeaderChildrenContext.Provider
|
|
35
|
+
value={{
|
|
36
|
+
headerChildren: content,
|
|
37
|
+
headerMobileChildren: mobileContent ?? null,
|
|
38
|
+
headerRootLabel: rootLabel ?? null,
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
29
41
|
{children}
|
|
30
42
|
</HeaderChildrenContext.Provider>
|
|
31
43
|
);
|
|
@@ -40,6 +52,15 @@ export function useHeaderChildren(): ReactNode | null {
|
|
|
40
52
|
return context.headerChildren;
|
|
41
53
|
}
|
|
42
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Hook to get the header content the application wants kept on mobile.
|
|
57
|
+
* Returns null when the application supplied none, in which case the Header shows no widgets there.
|
|
58
|
+
*/
|
|
59
|
+
export function useHeaderMobileChildren(): ReactNode | null {
|
|
60
|
+
const context = useContext(HeaderChildrenContext);
|
|
61
|
+
return context.headerMobileChildren;
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
/**
|
|
44
65
|
* Hook to get the label of the first breadcrumb entry, when the application supplies one.
|
|
45
66
|
* Returns null when no override was provided, in which case the Header falls back to `common.home`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
interface HeaderLogoContextType {
|
|
6
|
+
headerLogo: ReactNode | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const HeaderLogoContext = createContext<HeaderLogoContextType>({
|
|
10
|
+
headerLogo: null,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
interface HeaderLogoProviderProps {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
content: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function HeaderLogoProvider({ children, content }: HeaderLogoProviderProps) {
|
|
19
|
+
return <HeaderLogoContext.Provider value={{ headerLogo: content }}>{children}</HeaderLogoContext.Provider>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useHeaderLogo(): ReactNode | null {
|
|
23
|
+
const context = useContext(HeaderLogoContext);
|
|
24
|
+
return context.headerLogo;
|
|
25
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { getViewportContext, measureViewport, persistViewport, subscribeViewport } from "../utils/use-mobile";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Seeds the viewport from the server so SSR and the first client render agree,
|
|
8
|
+
* removing the desktop-then-mobile chrome flash.
|
|
9
|
+
*
|
|
10
|
+
* `initialIsMobile` comes from the request — cookie first (a real measurement
|
|
11
|
+
* from a previous visit), user agent as the first-visit fallback. See the
|
|
12
|
+
* (main)/(admin) layouts.
|
|
13
|
+
*
|
|
14
|
+
* THIS LIVES IN `contexts/`, NOT `utils/`, AND MUST STAY HERE. The server
|
|
15
|
+
* layouts render it directly, so it needs a genuine "use client" boundary in
|
|
16
|
+
* the BUILT output. tsup.config.ts stamps the directive onto `dist/contexts/*`
|
|
17
|
+
* via its `clientEntries` list; `dist/core/*` is deliberately excluded so that
|
|
18
|
+
* instrumentation can import it server-side. Exporting this provider from
|
|
19
|
+
* `core` therefore made Next execute it as a Server Component and fail with
|
|
20
|
+
* "createContext only works in Client Components".
|
|
21
|
+
*
|
|
22
|
+
* `SocketContext` in this directory is the reference: it is likewise rendered
|
|
23
|
+
* straight from `(main)/layout.tsx`, a Server Component, and works.
|
|
24
|
+
*/
|
|
25
|
+
export function ViewportProvider({
|
|
26
|
+
initialIsMobile,
|
|
27
|
+
children,
|
|
28
|
+
}: {
|
|
29
|
+
initialIsMobile: boolean;
|
|
30
|
+
children: React.ReactNode;
|
|
31
|
+
}) {
|
|
32
|
+
const ViewportContext = getViewportContext();
|
|
33
|
+
const [isMobile, setIsMobile] = React.useState<boolean>(initialIsMobile);
|
|
34
|
+
|
|
35
|
+
React.useEffect(
|
|
36
|
+
() =>
|
|
37
|
+
subscribeViewport(() => {
|
|
38
|
+
const next = measureViewport();
|
|
39
|
+
setIsMobile(next);
|
|
40
|
+
persistViewport(next);
|
|
41
|
+
}),
|
|
42
|
+
[],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return <ViewportContext.Provider value={isMobile}>{children}</ViewportContext.Provider>;
|
|
46
|
+
}
|
package/src/contexts/index.ts
CHANGED
|
@@ -9,8 +9,10 @@ export * from "../features/assistant/contexts/AssistantContext";
|
|
|
9
9
|
export * from "./CommonContext";
|
|
10
10
|
export * from "./HeaderChildrenContext";
|
|
11
11
|
export * from "./HeaderLeftContentContext";
|
|
12
|
+
export * from "./HeaderLogoContext";
|
|
12
13
|
export * from "./SharedContext";
|
|
13
14
|
export * from "./SocketContext";
|
|
15
|
+
export * from "./ViewportContext";
|
|
14
16
|
|
|
15
17
|
// Jotai atoms (merged from /atoms entry point)
|
|
16
18
|
export * from "../atoms";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const VIEWPORT_CONTEXT_KEY = Symbol.for("nextjs-jsonapi:viewportContext");
|
|
4
|
+
|
|
5
|
+
type GlobalStore = Record<symbol, unknown>;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.resetModules();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("viewportStore", () => {
|
|
12
|
+
/**
|
|
13
|
+
* Regression guard. `apps/web/src/instrumentation.ts` pulls this module into
|
|
14
|
+
* the Node/RSC server graph at startup (jsonapi.config -> Bootstrapper ->
|
|
15
|
+
* @carlonicora/nextjs-jsonapi/core -> utils barrel -> use-mobile ->
|
|
16
|
+
* viewportStore). React resolves to the vendored RSC build there, which omits
|
|
17
|
+
* `createContext`. A module-scope call crashed the dev server on boot with
|
|
18
|
+
* "createContext is not a function". Importing must stay side-effect free.
|
|
19
|
+
*/
|
|
20
|
+
it("does not create the context merely by being imported", async () => {
|
|
21
|
+
const store = globalThis as unknown as GlobalStore;
|
|
22
|
+
const previous = store[VIEWPORT_CONTEXT_KEY];
|
|
23
|
+
delete store[VIEWPORT_CONTEXT_KEY];
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
vi.resetModules();
|
|
27
|
+
await import("../viewportStore");
|
|
28
|
+
expect(store[VIEWPORT_CONTEXT_KEY]).toBeUndefined();
|
|
29
|
+
} finally {
|
|
30
|
+
store[VIEWPORT_CONTEXT_KEY] = previous;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("creates the context on first call and returns a stable identity after", async () => {
|
|
35
|
+
const store = globalThis as unknown as GlobalStore;
|
|
36
|
+
const previous = store[VIEWPORT_CONTEXT_KEY];
|
|
37
|
+
delete store[VIEWPORT_CONTEXT_KEY];
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
vi.resetModules();
|
|
41
|
+
const { getViewportContext } = await import("../viewportStore");
|
|
42
|
+
|
|
43
|
+
const first = getViewportContext();
|
|
44
|
+
expect(first).toBeDefined();
|
|
45
|
+
expect(getViewportContext()).toBe(first);
|
|
46
|
+
} finally {
|
|
47
|
+
store[VIEWPORT_CONTEXT_KEY] = previous;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Centralized viewport context object.
|
|
5
|
+
*
|
|
6
|
+
* Uses a globalThis Symbol key so that tsup's multi-entry `splitting: true`
|
|
7
|
+
* build cannot produce two distinct React context objects. If it did, a
|
|
8
|
+
* ViewportProvider from one bundle graph and a useIsMobile from another would
|
|
9
|
+
* silently miss each other: the hook would read `undefined`, fall back to the
|
|
10
|
+
* legacy path, and the flash would return with nothing failing loudly.
|
|
11
|
+
*
|
|
12
|
+
* CREATION IS LAZY, AND MUST STAY THAT WAY. Do not hoist the `createContext`
|
|
13
|
+
* call to module scope. `apps/web/src/instrumentation.ts` imports
|
|
14
|
+
* `jsonapi.config.ts` -> `Bootstrapper.ts` -> `@carlonicora/nextjs-jsonapi/core`,
|
|
15
|
+
* whose barrel re-exports `../utils` and therefore pulls this module into the
|
|
16
|
+
* Node/RSC server graph at server startup. In that graph Next resolves `react`
|
|
17
|
+
* to the vendored RSC build, which deliberately omits `createContext` because
|
|
18
|
+
* it is a client-only API. A module-scope call there crashes the dev server on
|
|
19
|
+
* boot with "createContext is not a function" — the `"use client"` directive
|
|
20
|
+
* does not help, because instrumentation is not a React render.
|
|
21
|
+
*
|
|
22
|
+
* Calling the accessor only from inside the provider/hook guarantees it runs in
|
|
23
|
+
* a rendering context, where `createContext` exists.
|
|
24
|
+
*
|
|
25
|
+
* NO external dependencies beyond React, to avoid circular imports.
|
|
26
|
+
*
|
|
27
|
+
* Pattern mirrors `helpStore.ts` / `bootstrapStore.ts`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createContext, type Context } from "react";
|
|
31
|
+
|
|
32
|
+
const VIEWPORT_CONTEXT_KEY = Symbol.for("nextjs-jsonapi:viewportContext");
|
|
33
|
+
|
|
34
|
+
const globalStore = globalThis as unknown as {
|
|
35
|
+
[VIEWPORT_CONTEXT_KEY]?: Context<boolean | undefined>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the process-wide viewport context, creating it on first use.
|
|
40
|
+
* Never call this at module scope — see the note above.
|
|
41
|
+
*/
|
|
42
|
+
export function getViewportContext(): Context<boolean | undefined> {
|
|
43
|
+
if (globalStore[VIEWPORT_CONTEXT_KEY] === undefined) {
|
|
44
|
+
globalStore[VIEWPORT_CONTEXT_KEY] = createContext<boolean | undefined>(undefined);
|
|
45
|
+
}
|
|
46
|
+
return globalStore[VIEWPORT_CONTEXT_KEY]!;
|
|
47
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { render, screen, act } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useIsMobile, VIEWPORT_COOKIE_NAME } from "../use-mobile";
|
|
4
|
+
// The provider lives in `contexts/` (not `utils/`) because the server layouts
|
|
5
|
+
// render it directly and only `dist/contexts/*` gets tsup's "use client" banner.
|
|
6
|
+
import { ViewportProvider } from "../../contexts/ViewportContext";
|
|
7
|
+
|
|
8
|
+
function Probe() {
|
|
9
|
+
const isMobile = useIsMobile();
|
|
10
|
+
return <span data-testid="probe">{isMobile ? "mobile" : "desktop"}</span>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function setWidth(width: number) {
|
|
14
|
+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: width });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
setWidth(1280);
|
|
19
|
+
document.cookie = `${VIEWPORT_COOKIE_NAME}=; path=/; max-age=0`;
|
|
20
|
+
vi.mocked(window.matchMedia).mockClear();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("server-graph safety", () => {
|
|
24
|
+
/**
|
|
25
|
+
* Regression guard. This module is reachable from the RSC/Node graph:
|
|
26
|
+
* apps/web instrumentation.ts -> jsonapi.config -> Bootstrapper ->
|
|
27
|
+
* @carlonicora/nextjs-jsonapi/core -> utils barrel -> use-mobile.
|
|
28
|
+
* Creating the context at module scope crashed the dev server on boot
|
|
29
|
+
* ("createContext is not a function"), because React resolves to the
|
|
30
|
+
* vendored RSC build there. Importing must stay side-effect free.
|
|
31
|
+
*/
|
|
32
|
+
it("does not create the context merely by being imported", async () => {
|
|
33
|
+
const key = Symbol.for("nextjs-jsonapi:viewportContext");
|
|
34
|
+
const store = globalThis as unknown as Record<symbol, unknown>;
|
|
35
|
+
const previous = store[key];
|
|
36
|
+
delete store[key];
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
vi.resetModules();
|
|
40
|
+
await import("../use-mobile");
|
|
41
|
+
expect(store[key]).toBeUndefined();
|
|
42
|
+
} finally {
|
|
43
|
+
store[key] = previous;
|
|
44
|
+
vi.resetModules();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("creates the context on first call and returns a stable identity after", async () => {
|
|
49
|
+
const key = Symbol.for("nextjs-jsonapi:viewportContext");
|
|
50
|
+
const store = globalThis as unknown as Record<symbol, unknown>;
|
|
51
|
+
const previous = store[key];
|
|
52
|
+
delete store[key];
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
vi.resetModules();
|
|
56
|
+
const { getViewportContext } = await import("../use-mobile");
|
|
57
|
+
const first = getViewportContext();
|
|
58
|
+
expect(first).toBeDefined();
|
|
59
|
+
expect(getViewportContext()).toBe(first);
|
|
60
|
+
} finally {
|
|
61
|
+
store[key] = previous;
|
|
62
|
+
vi.resetModules();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("useIsMobile with a ViewportProvider", () => {
|
|
68
|
+
it("returns the server seed on the first render, before any measurement", () => {
|
|
69
|
+
setWidth(1280);
|
|
70
|
+
const seen: string[] = [];
|
|
71
|
+
function Recorder() {
|
|
72
|
+
const isMobile = useIsMobile();
|
|
73
|
+
seen.push(isMobile ? "mobile" : "desktop");
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
render(
|
|
77
|
+
<ViewportProvider initialIsMobile={true}>
|
|
78
|
+
<Recorder />
|
|
79
|
+
</ViewportProvider>,
|
|
80
|
+
);
|
|
81
|
+
expect(seen[0]).toBe("mobile");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("corrects a wrong seed after mounting and persists the measurement", () => {
|
|
85
|
+
setWidth(1280);
|
|
86
|
+
render(
|
|
87
|
+
<ViewportProvider initialIsMobile={true}>
|
|
88
|
+
<Probe />
|
|
89
|
+
</ViewportProvider>,
|
|
90
|
+
);
|
|
91
|
+
expect(screen.getByTestId("probe").textContent).toBe("desktop");
|
|
92
|
+
expect(document.cookie).toContain(`${VIEWPORT_COOKIE_NAME}=0`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps a correct seed and persists it", () => {
|
|
96
|
+
setWidth(500);
|
|
97
|
+
render(
|
|
98
|
+
<ViewportProvider initialIsMobile={true}>
|
|
99
|
+
<Probe />
|
|
100
|
+
</ViewportProvider>,
|
|
101
|
+
);
|
|
102
|
+
expect(screen.getByTestId("probe").textContent).toBe("mobile");
|
|
103
|
+
expect(document.cookie).toContain(`${VIEWPORT_COOKIE_NAME}=1`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("follows a viewport change through the matchMedia listener", () => {
|
|
107
|
+
setWidth(1280);
|
|
108
|
+
render(
|
|
109
|
+
<ViewportProvider initialIsMobile={false}>
|
|
110
|
+
<Probe />
|
|
111
|
+
</ViewportProvider>,
|
|
112
|
+
);
|
|
113
|
+
expect(screen.getByTestId("probe").textContent).toBe("desktop");
|
|
114
|
+
|
|
115
|
+
const mql = vi.mocked(window.matchMedia).mock.results[0].value;
|
|
116
|
+
const handler = mql.addEventListener.mock.calls[0][1] as () => void;
|
|
117
|
+
setWidth(500);
|
|
118
|
+
act(() => handler());
|
|
119
|
+
|
|
120
|
+
expect(screen.getByTestId("probe").textContent).toBe("mobile");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("useIsMobile without a provider (legacy fallback)", () => {
|
|
125
|
+
it("measures the viewport itself and does not write a cookie", () => {
|
|
126
|
+
setWidth(500);
|
|
127
|
+
render(<Probe />);
|
|
128
|
+
expect(screen.getByTestId("probe").textContent).toBe("mobile");
|
|
129
|
+
expect(document.cookie).not.toContain(`${VIEWPORT_COOKIE_NAME}=`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("reports desktop for a wide viewport", () => {
|
|
133
|
+
setWidth(1280);
|
|
134
|
+
render(<Probe />);
|
|
135
|
+
expect(screen.getByTestId("probe").textContent).toBe("desktop");
|
|
136
|
+
});
|
|
137
|
+
});
|
package/src/utils/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type { ClassValue } from "clsx";
|
|
2
2
|
export { cn } from "./cn";
|
|
3
3
|
export { composeRefs, useComposedRefs } from "./compose-refs";
|
|
4
|
-
export { useIsMobile } from "./use-mobile";
|
|
4
|
+
export { useIsMobile, MOBILE_BREAKPOINT, VIEWPORT_COOKIE_NAME } from "./use-mobile";
|
|
5
5
|
|
|
6
6
|
// New utilities
|
|
7
7
|
export { formatDate, formatLocalDate, type FormatOption } from "./date-formatter";
|
package/src/utils/use-mobile.tsx
CHANGED
|
@@ -2,20 +2,77 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
|
|
5
|
-
const MOBILE_BREAKPOINT = 768;
|
|
5
|
+
export const MOBILE_BREAKPOINT = 768;
|
|
6
|
+
export const VIEWPORT_COOKIE_NAME = "viewport_mobile";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The viewport context is keyed on a globalThis Symbol so that tsup's
|
|
10
|
+
* multi-entry `splitting: true` build cannot produce two distinct context
|
|
11
|
+
* objects. If it did, the ViewportProvider (built into the `contexts` entry)
|
|
12
|
+
* and this hook (built into the `core` entry) would silently miss each other:
|
|
13
|
+
* the hook would read `undefined`, fall back to the legacy path, and the
|
|
14
|
+
* mobile-layout flash would return with nothing failing loudly.
|
|
15
|
+
*
|
|
16
|
+
* THIS MODULE IS SERVER-REACHABLE. `core` is deliberately absent from
|
|
17
|
+
* tsup.config.ts's `clientEntries` list, so `dist/core/*` gets NO "use client"
|
|
18
|
+
* banner — that is what lets `apps/web/src/instrumentation.ts` import
|
|
19
|
+
* jsonapi.config -> Bootstrapper -> `@carlonicora/nextjs-jsonapi/core` at
|
|
20
|
+
* server startup. Two rules follow, both load-bearing:
|
|
21
|
+
*
|
|
22
|
+
* 1. React is imported as a NAMESPACE. A named
|
|
23
|
+
* `import { createContext } from "react"` makes Turbopack statically refuse
|
|
24
|
+
* the module: "You're importing a module that depends on `createContext`
|
|
25
|
+
* into a React Server Component module."
|
|
26
|
+
* 2. Creation is LAZY, never at module scope. On the server React resolves to
|
|
27
|
+
* the vendored RSC build, which omits `createContext` entirely
|
|
28
|
+
* ("createContext is not a function" — crashes the dev server at boot).
|
|
29
|
+
*
|
|
30
|
+
* `useIsMobile` only ever runs inside a Client Component render, so calling the
|
|
31
|
+
* accessor from there is safe. The PROVIDER must not live here — it is rendered
|
|
32
|
+
* by the server layouts, so it needs a real "use client" boundary and therefore
|
|
33
|
+
* lives in `contexts/ViewportContext.tsx`.
|
|
34
|
+
*/
|
|
35
|
+
const VIEWPORT_CONTEXT_KEY = Symbol.for("nextjs-jsonapi:viewportContext");
|
|
36
|
+
|
|
37
|
+
const globalStore = globalThis as unknown as {
|
|
38
|
+
[VIEWPORT_CONTEXT_KEY]?: React.Context<boolean | undefined>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Returns the process-wide viewport context, creating it on first use. */
|
|
42
|
+
export function getViewportContext(): React.Context<boolean | undefined> {
|
|
43
|
+
if (globalStore[VIEWPORT_CONTEXT_KEY] === undefined) {
|
|
44
|
+
globalStore[VIEWPORT_CONTEXT_KEY] = React.createContext<boolean | undefined>(undefined);
|
|
45
|
+
}
|
|
46
|
+
return globalStore[VIEWPORT_CONTEXT_KEY]!;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @internal — consumed by contexts/ViewportContext.tsx */
|
|
50
|
+
export function measureViewport(): boolean {
|
|
51
|
+
return window.innerWidth < MOBILE_BREAKPOINT;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @internal — consumed by contexts/ViewportContext.tsx */
|
|
55
|
+
export function persistViewport(isMobile: boolean): void {
|
|
56
|
+
document.cookie = `${VIEWPORT_COOKIE_NAME}=${isMobile ? "1" : "0"}; path=/; max-age=31536000; samesite=lax`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @internal — consumed by contexts/ViewportContext.tsx */
|
|
60
|
+
export function subscribeViewport(onChange: () => void): () => void {
|
|
61
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
62
|
+
onChange();
|
|
63
|
+
mql.addEventListener("change", onChange);
|
|
64
|
+
return () => mql.removeEventListener("change", onChange);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function useIsMobile(): boolean {
|
|
68
|
+
const seeded = React.useContext(getViewportContext());
|
|
69
|
+
const [legacy, setLegacy] = React.useState<boolean>(false);
|
|
9
70
|
|
|
10
71
|
React.useEffect(() => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return () => mql.removeEventListener("change", onChange);
|
|
18
|
-
}, []);
|
|
19
|
-
|
|
20
|
-
return !!isMobile;
|
|
72
|
+
// A provider owns the value; never run the legacy measurement alongside it.
|
|
73
|
+
if (seeded !== undefined) return;
|
|
74
|
+
return subscribeViewport(() => setLegacy(measureViewport()));
|
|
75
|
+
}, [seeded]);
|
|
76
|
+
|
|
77
|
+
return seeded ?? legacy;
|
|
21
78
|
}
|