@mohasinac/appkit 4.35.3 → 4.35.4

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.
@@ -3,6 +3,8 @@ import { useEffect } from "react";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
  import { apiClient } from "../../../http";
5
5
  import { useToast } from "../../../ui";
6
+ import { useOptionalSession } from "../../../react/contexts/SessionContext";
7
+ import { isAdminUser, isTesterUser } from "../../auth/role-predicates";
6
8
  import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints";
7
9
  export function useProducts(params = {}, opts) {
8
10
  const { showToast } = useToast();
@@ -73,11 +75,46 @@ export function useProducts(params = {}, opts) {
73
75
  sp.set(key, value);
74
76
  }
75
77
  const qs = sp.toString();
78
+ /*
79
+ * 🛑 A TESTER MUST REFETCH; EVERYONE ELSE MUST KEEP THE CACHED SSR PAINT.
80
+ *
81
+ * Sandbox rows are correctly hidden from the public. But the SSR listing views
82
+ * pass no `viewer` to `listPublicProducts`, so the server-rendered grid is the
83
+ * anonymous one for EVERY visitor — and with `initialData` + `staleTime:
84
+ * Infinity` the client never refetches, so a signed-in tester saw a catalogue
85
+ * with no sandbox fixtures in it, on every listing page. The items were
86
+ * reachable by direct URL, which is what made it look like a data problem
87
+ * rather than a visibility one.
88
+ *
89
+ * The fix is NOT to read the session in those SSR views. `getServerSessionUser`
90
+ * is a dynamic API, and calling it there would make every public listing page
91
+ * per-request — no caching, a billed invocation per visitor, and every
92
+ * `revalidate` silently overridden. That is Root Cause #82, and paying it for a
93
+ * testing affordance would be a poor trade.
94
+ *
95
+ * `/api/products` ALREADY resolves the session and passes `viewer`. So the only
96
+ * thing missing was a reason for the client to ask again: the query key now
97
+ * carries the viewer class, giving testers their own cache entry, and their
98
+ * `staleTime` is not frozen. Anonymous visitors keep the cached SSR data
99
+ * untouched and issue no extra request.
100
+ *
101
+ * `useOptionalSession` rather than `useAuth()` on purpose — `useSession`
102
+ * THROWS outside a provider, and making a public appkit hook require one would
103
+ * be a breaking contract change (Root Cause #20). Absent provider degrades to
104
+ * "not a tester", which is the fail-closed answer.
105
+ */
106
+ const session = useOptionalSession();
107
+ const viewer = session?.user;
108
+ const canSeeTestData = isTesterUser(viewer) || isAdminUser(viewer);
76
109
  const query = useQuery({
77
- queryKey: ["products", qs],
110
+ queryKey: ["products", qs, canSeeTestData ? "with-test-data" : "public"],
78
111
  queryFn: () => apiClient.get(`${PRODUCT_ENDPOINTS.LIST}${qs ? `?${qs}` : ""}`),
79
- initialData: opts?.initialData,
80
- staleTime: opts?.staleTime ?? (opts?.initialData != null ? Infinity : 0),
112
+ // The SSR paint is the anonymous view, so it is only a valid starting point
113
+ // for an anonymous viewer. Handing it to a tester would show them the very
114
+ // rows this refetch exists to reveal.
115
+ initialData: canSeeTestData ? undefined : opts?.initialData,
116
+ staleTime: opts?.staleTime ??
117
+ (!canSeeTestData && opts?.initialData != null ? Infinity : 0),
81
118
  enabled: opts?.enabled,
82
119
  });
83
120
  const warning = query.data?.warning;
@@ -95,6 +95,19 @@ export interface SessionProviderProps {
95
95
  }
96
96
  export declare function SessionProvider({ children, initialUser, endpoints: endpointOverrides, onSignOutInvalidate, signOutInvalidationKeys, }: SessionProviderProps): React.JSX.Element;
97
97
  export declare function useSession(): SessionContextValue;
98
+ /**
99
+ * The session if a provider is mounted, `null` if not — never throws.
100
+ *
101
+ * `useSession` throwing is right for a component that genuinely cannot work
102
+ * signed-out. It is wrong for library code that merely wants to ADJUST for the
103
+ * viewer, because requiring a provider there turns an optional refinement into a
104
+ * breaking contract change for every consumer (Root Cause #20).
105
+ *
106
+ * Use this only where "no provider" has a sensible, fail-closed answer — e.g.
107
+ * `useProducts`, which treats an absent session as "not a tester" and keeps the
108
+ * public cached view.
109
+ */
110
+ export declare function useOptionalSession(): SessionContextValue | null;
98
111
  export declare function useAuth(): {
99
112
  user: SessionUser | null;
100
113
  loading: boolean;
@@ -394,6 +394,21 @@ export function useSession() {
394
394
  }
395
395
  return context;
396
396
  }
397
+ /**
398
+ * The session if a provider is mounted, `null` if not — never throws.
399
+ *
400
+ * `useSession` throwing is right for a component that genuinely cannot work
401
+ * signed-out. It is wrong for library code that merely wants to ADJUST for the
402
+ * viewer, because requiring a provider there turns an optional refinement into a
403
+ * breaking contract change for every consumer (Root Cause #20).
404
+ *
405
+ * Use this only where "no provider" has a sensible, fail-closed answer — e.g.
406
+ * `useProducts`, which treats an absent session as "not a tester" and keeps the
407
+ * public cached view.
408
+ */
409
+ export function useOptionalSession() {
410
+ return useContext(SessionContext) ?? null;
411
+ }
397
412
  export function useAuth() {
398
413
  const { user, loading, refreshUser } = useSession();
399
414
  return { user, loading, refreshUser };
@@ -83,7 +83,26 @@ export function ImageLightbox({ images, activeIndex, onClose, onNavigate, showTh
83
83
  adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
84
84
  }, [adjustZoom]);
85
85
  const handleKeyDown = useCallback((e) => {
86
+ /*
87
+ * Escape unwinds ONE level: fullscreen first, the lightbox second.
88
+ *
89
+ * Unconditionally calling `onClose()` meant Escape in fullscreen tore the
90
+ * whole lightbox down while the browser was natively exiting fullscreen at
91
+ * the same moment — the user asked to leave fullscreen and lost the image
92
+ * they were looking at. The Exit-fullscreen BUTTON behaved correctly, which
93
+ * is what made this read as a keyboard quirk rather than a bug.
94
+ *
95
+ * Exiting explicitly rather than relying on the browser's own handling: the
96
+ * `fullscreenchange` listener above is what clears `isFullscreen`, so going
97
+ * through the same path keeps the button label and this state in agreement
98
+ * whichever way the user leaves.
99
+ */
86
100
  if (e.key === "Escape") {
101
+ if (isFullscreen) {
102
+ const doc = document;
103
+ (document.exitFullscreen?.() ?? doc.webkitExitFullscreen?.())?.catch?.(() => { });
104
+ return;
105
+ }
87
106
  onClose();
88
107
  return;
89
108
  }
@@ -112,7 +131,11 @@ export function ImageLightbox({ images, activeIndex, onClose, onNavigate, showTh
112
131
  setRotation(0);
113
132
  return;
114
133
  }
115
- }, [onClose, navigate, adjustZoom]);
134
+ },
135
+ // `isFullscreen` is a real dependency now — Escape branches on it, and a
136
+ // stale closure would send the user back to the pre-fix behaviour exactly
137
+ // when they are in fullscreen.
138
+ [onClose, navigate, adjustZoom, isFullscreen]);
116
139
  // Focus overlay on open for keyboard to work
117
140
  useEffect(() => {
118
141
  if (isOpen)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.35.3",
3
+ "version": "4.35.4",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"