@decocms/blocks 7.0.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/package.json +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side device detection via RequestContext.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```tsx
|
|
6
|
+
* // In a section loader or server function:
|
|
7
|
+
* import { detectDevice } from "@decocms/start/sdk/useDevice";
|
|
8
|
+
*
|
|
9
|
+
* export function loader(props: Props, req: Request) {
|
|
10
|
+
* const device = detectDevice(req.headers.get("user-agent") ?? "");
|
|
11
|
+
* return { ...props, device };
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* // Or via RequestContext (no request argument needed):
|
|
15
|
+
* import { useDevice } from "@decocms/start/sdk/useDevice";
|
|
16
|
+
*
|
|
17
|
+
* export function loader(props: Props) {
|
|
18
|
+
* const device = useDevice();
|
|
19
|
+
* return { ...props, isMobile: device === "mobile" };
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { RequestContext } from "./requestContext";
|
|
25
|
+
|
|
26
|
+
export type Device = "mobile" | "tablet" | "desktop";
|
|
27
|
+
|
|
28
|
+
// `DeviceContext`, the `useDevice()` hook, and `DeviceProvider` live in
|
|
29
|
+
// `./useDeviceContext` (a `"use client"` file) and are re-exported below for
|
|
30
|
+
// full backward compatibility with this module's public API — see that
|
|
31
|
+
// file's doc comment for why the split exists. Everything that stays in
|
|
32
|
+
// *this* file is a plain, hook-free function, safe to import from
|
|
33
|
+
// server-only code (e.g. `cms/sectionMixins.ts`'s `withDevice()`/
|
|
34
|
+
// `withMobile()` loader mixins) without dragging a client-only hook/context
|
|
35
|
+
// boundary into a Server Component's module graph.
|
|
36
|
+
export { DeviceContext, useDevice, DeviceProvider } from "./useDeviceContext";
|
|
37
|
+
|
|
38
|
+
// Android phones include "Mobile" in their UA; Android tablets do not.
|
|
39
|
+
// Check TABLET_RE first so `android(?!.*mobile)` captures tablets before
|
|
40
|
+
// the MOBILE_RE `android.*mobile` branch matches phones.
|
|
41
|
+
export const MOBILE_RE = /mobile|android.*mobile|iphone|ipod|webos|blackberry|opera mini|iemobile/i;
|
|
42
|
+
export const TABLET_RE = /ipad|tablet|kindle|silk|playbook|android(?!.*mobile)/i;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Simple mobile-or-not check (mobile + tablet = true).
|
|
46
|
+
* Use this for cache key splitting or any context where you
|
|
47
|
+
* only need a mobile/desktop binary decision.
|
|
48
|
+
*/
|
|
49
|
+
export function isMobileUA(userAgent: string): boolean {
|
|
50
|
+
return MOBILE_RE.test(userAgent) || TABLET_RE.test(userAgent);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Detect device type from a User-Agent string.
|
|
55
|
+
* Pure function — no side effects, works anywhere.
|
|
56
|
+
*/
|
|
57
|
+
export function detectDevice(userAgent: string): Device {
|
|
58
|
+
if (TABLET_RE.test(userAgent)) return "tablet";
|
|
59
|
+
if (MOBILE_RE.test(userAgent)) return "mobile";
|
|
60
|
+
return "desktop";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the current device from the ambient runtime (RequestContext on the
|
|
65
|
+
* server, `navigator.userAgent` on the client) — no React hooks involved.
|
|
66
|
+
* Used both by the plain `check*()` helpers below and by `useDevice()`/
|
|
67
|
+
* `DeviceProvider` in `./useDeviceContext`.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveDeviceFromRuntime(): Device {
|
|
70
|
+
// Server: use RequestContext UA header
|
|
71
|
+
if (typeof document === "undefined") {
|
|
72
|
+
const ctx = RequestContext.current;
|
|
73
|
+
if (!ctx) return "desktop";
|
|
74
|
+
const ua = ctx.request.headers.get("user-agent") ?? "";
|
|
75
|
+
return detectDevice(ua);
|
|
76
|
+
}
|
|
77
|
+
// Client: use navigator.userAgent for consistency with server-side UA detection.
|
|
78
|
+
// Using viewport width would produce different results between SSR and
|
|
79
|
+
// hydration (server sees UA, client sees pixels), causing hydration mismatch.
|
|
80
|
+
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
|
81
|
+
return detectDevice(ua);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if the current request is from a mobile device.
|
|
86
|
+
*/
|
|
87
|
+
export function checkMobile(): boolean {
|
|
88
|
+
const ctx = RequestContext.current;
|
|
89
|
+
if (!ctx) return false;
|
|
90
|
+
return detectDevice(ctx.request.headers.get("user-agent") ?? "") === "mobile";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if the current request is from a tablet device.
|
|
95
|
+
*/
|
|
96
|
+
export function checkTablet(): boolean {
|
|
97
|
+
const ctx = RequestContext.current;
|
|
98
|
+
if (!ctx) return false;
|
|
99
|
+
return detectDevice(ctx.request.headers.get("user-agent") ?? "") === "tablet";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if the current request is from a desktop device.
|
|
104
|
+
*/
|
|
105
|
+
export function checkDesktop(): boolean {
|
|
106
|
+
const ctx = RequestContext.current;
|
|
107
|
+
if (!ctx) return true;
|
|
108
|
+
return detectDevice(ctx.request.headers.get("user-agent") ?? "") === "desktop";
|
|
109
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The hook/context half of device detection — split out of `useDevice.ts`
|
|
5
|
+
* because it uses `createContext`/`useContext`, which Next.js's App Router
|
|
6
|
+
* (unlike the Vite-based `@decocms/tanstack` binding, and unlike this
|
|
7
|
+
* package's own Vitest/jsdom unit tests) statically rejects unless the
|
|
8
|
+
* *file* that calls them carries a `"use client"` directive — Next's RSC
|
|
9
|
+
* webpack loader errors ("This React Hook only works in a Client
|
|
10
|
+
* Component") on ANY file reachable from a Server Component's module graph
|
|
11
|
+
* that calls a client-only hook, even if the actual call site is never
|
|
12
|
+
* invoked along that particular path.
|
|
13
|
+
*
|
|
14
|
+
* That's exactly what happened here: `packages/blocks/src/cms/sectionMixins.ts`
|
|
15
|
+
* imports only the plain, hook-free `detectDevice()` from `useDevice.ts` for
|
|
16
|
+
* its `withDevice()`/`withMobile()` section-loader mixins — a 100%
|
|
17
|
+
* server-side call path (loaders receive a `Request`, never render). But
|
|
18
|
+
* because `sectionMixins.ts` is pulled in by `cms/index.ts`, which
|
|
19
|
+
* `createSiteSetup()` (runtime/setup.ts) imports, and a site's `setup.ts` is
|
|
20
|
+
* typically imported for its side effects from `app/layout.tsx` (a Server
|
|
21
|
+
* Component under `@decocms/nextjs`), the *whole* `useDevice.ts` file used to
|
|
22
|
+
* ride along into the server compilation graph — including the
|
|
23
|
+
* `createContext`/`useContext` calls now isolated in this file, which had no
|
|
24
|
+
* "use client" directive of their own. `@decocms/nextjs`'s next-smoke fixture
|
|
25
|
+
* (this plan's Task 9) is the first consumer to hit Next's real RSC
|
|
26
|
+
* compiler, so this split was never needed before.
|
|
27
|
+
*
|
|
28
|
+
* `detectDevice`, `isMobileUA`, `checkMobile/Tablet/Desktop`, and the
|
|
29
|
+
* `Device` type stay in `useDevice.ts` (no directive, safe to import from
|
|
30
|
+
* server-only code); `useDevice.ts` re-exports `DeviceContext`/`useDevice`/
|
|
31
|
+
* `DeviceProvider` from here so the public `@decocms/blocks/sdk/useDevice`
|
|
32
|
+
* entry point (and every existing consumer, e.g.
|
|
33
|
+
* `packages/tanstack/src/hooks/DecoPageRenderer.tsx`) is unchanged.
|
|
34
|
+
*/
|
|
35
|
+
import { createContext, createElement, type ReactNode, useContext } from "react";
|
|
36
|
+
import { type Device, resolveDeviceFromRuntime } from "./useDevice";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* React context for the resolved device. Populated by `<DeviceProvider>` at
|
|
40
|
+
* the top of the framework tree (DecoPageRenderer mounts it for sites that
|
|
41
|
+
* use the standard wiring). Once set, `useDevice()` reads from here in
|
|
42
|
+
* preference to `AsyncLocalStorage`, which is known to be unreliable across
|
|
43
|
+
* streaming SSR Suspense boundaries on Cloudflare Workers.
|
|
44
|
+
*/
|
|
45
|
+
export const DeviceContext = createContext<Device | null>(null);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the current device type. Works everywhere:
|
|
49
|
+
* - Server (loader, middleware, server function): reads User-Agent from RequestContext.
|
|
50
|
+
* - Client (component, event handler): uses `window.innerWidth` breakpoints.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* import { useDevice } from "@decocms/start/sdk/useDevice";
|
|
55
|
+
*
|
|
56
|
+
* // In a component:
|
|
57
|
+
* const device = useDevice(); // "mobile" | "tablet" | "desktop"
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function useDevice(): Device {
|
|
61
|
+
// Prefer the value resolved by <DeviceProvider> at the framework root —
|
|
62
|
+
// safe across streaming-SSR Suspense boundaries where AsyncLocalStorage
|
|
63
|
+
// can lose the request context. The try/catch keeps backward compat with
|
|
64
|
+
// callers outside a React render (loaders, server functions, tests
|
|
65
|
+
// without the framework root), where useContext throws "Invalid hook
|
|
66
|
+
// call" — those callers fall through to the original runtime resolution.
|
|
67
|
+
//
|
|
68
|
+
// Rules-of-hooks note: `useContext` is called *unconditionally* inside the
|
|
69
|
+
// try block — exactly once per `useDevice()` invocation. The catch only
|
|
70
|
+
// fires when there is no React dispatcher at all (outside a render),
|
|
71
|
+
// never *between* hooks in the same render. Hook order within a component
|
|
72
|
+
// therefore remains consistent. Callers that conditionally call
|
|
73
|
+
// `useDevice()` itself were already violating rules of hooks; this PR
|
|
74
|
+
// doesn't change that.
|
|
75
|
+
try {
|
|
76
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
77
|
+
const fromContext = useContext(DeviceContext);
|
|
78
|
+
if (fromContext) return fromContext;
|
|
79
|
+
} catch {
|
|
80
|
+
// Not in a React render — fall through.
|
|
81
|
+
}
|
|
82
|
+
return resolveDeviceFromRuntime();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Wraps children in a `DeviceContext` populated by resolving the device once
|
|
87
|
+
* here, at a point in the React tree where `AsyncLocalStorage` is reliable.
|
|
88
|
+
* Any descendant calling `useDevice()` reads from this context instead of
|
|
89
|
+
* re-resolving through ALS — preventing the "wrong device value cached at
|
|
90
|
+
* the edge" failure mode that produces React #418 hydration mismatches.
|
|
91
|
+
*
|
|
92
|
+
* Mount this near the top of the React tree. `DecoPageRenderer` already
|
|
93
|
+
* mounts it automatically; sites with custom roots can mount it explicitly:
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```tsx
|
|
97
|
+
* <DeviceProvider>
|
|
98
|
+
* <App />
|
|
99
|
+
* </DeviceProvider>
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* Pass an explicit `value` to override detection (useful for tests or
|
|
103
|
+
* admin preview where the runtime UA isn't meaningful).
|
|
104
|
+
*/
|
|
105
|
+
export function DeviceProvider(props: { children: ReactNode; value?: Device }): ReactNode {
|
|
106
|
+
const device = props.value ?? resolveDeviceFromRuntime();
|
|
107
|
+
return createElement(DeviceContext.Provider, { value: device }, props.children);
|
|
108
|
+
}
|
package/src/sdk/useId.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
HTMX_LEGACY_URL,
|
|
4
|
+
inlineScript,
|
|
5
|
+
usePartialSection,
|
|
6
|
+
useScript,
|
|
7
|
+
useSection,
|
|
8
|
+
} from "./useScript";
|
|
9
|
+
|
|
10
|
+
describe("inlineScript", () => {
|
|
11
|
+
it("returns dangerouslySetInnerHTML with the provided string", () => {
|
|
12
|
+
const result = inlineScript('alert("hello")');
|
|
13
|
+
expect(result).toEqual({
|
|
14
|
+
dangerouslySetInnerHTML: { __html: 'alert("hello")' },
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("handles empty string", () => {
|
|
19
|
+
const result = inlineScript("");
|
|
20
|
+
expect(result).toEqual({
|
|
21
|
+
dangerouslySetInnerHTML: { __html: "" },
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("handles multiline scripts", () => {
|
|
26
|
+
const js = `
|
|
27
|
+
const el = document.getElementById("btn");
|
|
28
|
+
el.addEventListener("click", () => { console.log("clicked"); });
|
|
29
|
+
`;
|
|
30
|
+
const result = inlineScript(js);
|
|
31
|
+
expect(result.dangerouslySetInnerHTML.__html).toBe(js);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("useScript", () => {
|
|
36
|
+
it("serializes a function into an IIFE string", () => {
|
|
37
|
+
function greet(name: string) {
|
|
38
|
+
console.log(name);
|
|
39
|
+
}
|
|
40
|
+
const result = useScript(greet, "world");
|
|
41
|
+
expect(result).toContain("(");
|
|
42
|
+
expect(result).toContain('"world"');
|
|
43
|
+
expect(result).toContain("console.log");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("serializes multiple arguments", () => {
|
|
47
|
+
function add(a: number, b: number) {
|
|
48
|
+
return a + b;
|
|
49
|
+
}
|
|
50
|
+
const result = useScript(add, 1, 2);
|
|
51
|
+
expect(result).toContain("1,2");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("handles functions with no arguments", () => {
|
|
55
|
+
function noop() {}
|
|
56
|
+
const result = useScript(noop);
|
|
57
|
+
expect(result).toMatch(/^\(.*\)\(\)$/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Legacy HTMX stubs — useSection / usePartialSection
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe("useSection / usePartialSection (legacy HTMX stubs)", () => {
|
|
66
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
67
|
+
let prevNodeEnv: string | undefined;
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
// Reset the dedup set so each test sees a fresh warning fire.
|
|
71
|
+
delete (globalThis as any).__DECO_LEGACY_HTMX_WARNED;
|
|
72
|
+
prevNodeEnv = process.env.NODE_ENV;
|
|
73
|
+
process.env.NODE_ENV = "development";
|
|
74
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
warnSpy.mockRestore();
|
|
79
|
+
if (prevNodeEnv === undefined) delete process.env.NODE_ENV;
|
|
80
|
+
else process.env.NODE_ENV = prevNodeEnv;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("useSection returns a stable placeholder URL instead of throwing", () => {
|
|
84
|
+
expect(useSection()).toBe(HTMX_LEGACY_URL);
|
|
85
|
+
expect(useSection({ props: { x: 1 } })).toBe(HTMX_LEGACY_URL);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("usePartialSection returns the same placeholder URL", () => {
|
|
89
|
+
expect(usePartialSection()).toBe(HTMX_LEGACY_URL);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("warns on first call (in development)", () => {
|
|
93
|
+
useSection();
|
|
94
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
95
|
+
expect(warnSpy.mock.calls[0][0]).toMatch(/useSection/);
|
|
96
|
+
expect(warnSpy.mock.calls[0][0]).toMatch(/were removed/);
|
|
97
|
+
expect(warnSpy.mock.calls[0][0]).toMatch(/htmx-residue/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("dedups warnings: a second call to the same stub is silent", () => {
|
|
101
|
+
useSection();
|
|
102
|
+
useSection();
|
|
103
|
+
useSection();
|
|
104
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("tracks warnings per-stub: useSection and usePartialSection warn independently", () => {
|
|
108
|
+
useSection();
|
|
109
|
+
usePartialSection();
|
|
110
|
+
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
111
|
+
const messages = warnSpy.mock.calls.map((c) => c[0] as string);
|
|
112
|
+
expect(messages.some((m) => m.includes("useSection"))).toBe(true);
|
|
113
|
+
expect(messages.some((m) => m.includes("usePartialSection"))).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("does NOT warn in production", () => {
|
|
117
|
+
process.env.NODE_ENV = "production";
|
|
118
|
+
useSection();
|
|
119
|
+
usePartialSection();
|
|
120
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns the placeholder URL even in production (still safe to embed)", () => {
|
|
124
|
+
process.env.NODE_ENV = "production";
|
|
125
|
+
expect(useSection()).toBe(HTMX_LEGACY_URL);
|
|
126
|
+
expect(usePartialSection()).toBe(HTMX_LEGACY_URL);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes functions into inline script strings for SSR output.
|
|
3
|
+
*
|
|
4
|
+
* Includes lightweight minification (whitespace + comment removal)
|
|
5
|
+
* with an LRU cache to avoid re-processing the same function bodies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// LRU cache for minified function bodies
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const MAX_CACHE_SIZE = 200;
|
|
13
|
+
const minifyCache = new Map<string, string>();
|
|
14
|
+
|
|
15
|
+
function cacheGet(key: string): string | undefined {
|
|
16
|
+
const value = minifyCache.get(key);
|
|
17
|
+
if (value !== undefined) {
|
|
18
|
+
// Move to end (most recently used)
|
|
19
|
+
minifyCache.delete(key);
|
|
20
|
+
minifyCache.set(key, value);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cacheSet(key: string, value: string) {
|
|
26
|
+
if (minifyCache.size >= MAX_CACHE_SIZE) {
|
|
27
|
+
// Delete oldest entry (first key in insertion order)
|
|
28
|
+
const oldest = minifyCache.keys().next().value;
|
|
29
|
+
if (oldest !== undefined) minifyCache.delete(oldest);
|
|
30
|
+
}
|
|
31
|
+
minifyCache.set(key, value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Lightweight minification (no external deps)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function minifyJs(code: string): string {
|
|
39
|
+
return (
|
|
40
|
+
code
|
|
41
|
+
// Remove block comments only — line-comment stripping and operator-spacing
|
|
42
|
+
// collapse are unsafe: they can corrupt string literals containing "//"
|
|
43
|
+
// or operators, and regex literals whose delimiters look like operators.
|
|
44
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
45
|
+
// Collapse runs of whitespace (spaces, tabs, newlines) to a single space.
|
|
46
|
+
// This is safe because whitespace inside string/template literals is
|
|
47
|
+
// preserved as a single space, which is semantically identical.
|
|
48
|
+
.replace(/\s+/g, " ")
|
|
49
|
+
.trim()
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Public API
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Serializes a function and its arguments into a self-executing inline script.
|
|
59
|
+
*
|
|
60
|
+
* @deprecated `fn.toString()` produces different output in SSR vs client Vite
|
|
61
|
+
* builds (React Compiler transforms differ), causing hydration mismatches on
|
|
62
|
+
* `dangerouslySetInnerHTML.__html`. Use {@link inlineScript} with a plain
|
|
63
|
+
* string constant instead.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```tsx
|
|
67
|
+
* // BEFORE (causes hydration mismatch):
|
|
68
|
+
* <script dangerouslySetInnerHTML={{ __html: useScript(onLoad, elementId) }} />
|
|
69
|
+
*
|
|
70
|
+
* // AFTER (safe):
|
|
71
|
+
* <script {...inlineScript(`(${MY_SCRIPT_STRING})("${elementId}")`)} />
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function useScript<T extends (...args: any[]) => void>(
|
|
75
|
+
fn: T,
|
|
76
|
+
...args: Parameters<T>
|
|
77
|
+
): string {
|
|
78
|
+
if (typeof (globalThis as any).__DECO_USE_SCRIPT_WARNED === "undefined") {
|
|
79
|
+
(globalThis as any).__DECO_USE_SCRIPT_WARNED = new Set<string>();
|
|
80
|
+
}
|
|
81
|
+
const warnedSet = (globalThis as any).__DECO_USE_SCRIPT_WARNED as Set<string>;
|
|
82
|
+
const fnName = fn.name || "anonymous";
|
|
83
|
+
if (
|
|
84
|
+
typeof process !== "undefined" &&
|
|
85
|
+
process.env?.NODE_ENV !== "production" &&
|
|
86
|
+
!warnedSet.has(fnName)
|
|
87
|
+
) {
|
|
88
|
+
warnedSet.add(fnName);
|
|
89
|
+
console.warn(
|
|
90
|
+
`[useScript] Using fn.toString() for "${fnName}". ` +
|
|
91
|
+
`This may produce different output in SSR vs client builds, causing hydration mismatch. ` +
|
|
92
|
+
`Consider using inlineScript() with a plain string constant instead.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const fnStr = fn.toString();
|
|
97
|
+
let minified = cacheGet(fnStr);
|
|
98
|
+
if (minified === undefined) {
|
|
99
|
+
minified = minifyJs(fnStr);
|
|
100
|
+
cacheSet(fnStr, minified);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const serializedArgs = args.map((a) => JSON.stringify(a)).join(",");
|
|
104
|
+
return `(${minified})(${serializedArgs})`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Like useScript, but returns a data: URI suitable for `<script src="...">`.
|
|
109
|
+
*
|
|
110
|
+
* @deprecated Same hydration issues as {@link useScript}. Use {@link inlineScript} instead.
|
|
111
|
+
*/
|
|
112
|
+
export function useScriptAsDataURI<T extends (...args: any[]) => void>(
|
|
113
|
+
fn: T,
|
|
114
|
+
...args: Parameters<T>
|
|
115
|
+
): string {
|
|
116
|
+
const code = useScript(fn, ...args);
|
|
117
|
+
return `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Returns props for a `<script>` element with safe inline JavaScript.
|
|
122
|
+
* Unlike {@link useScript}, this accepts a plain string — no `fn.toString()`
|
|
123
|
+
* means no SSR/client divergence and no hydration mismatch.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```tsx
|
|
127
|
+
* const SCROLL_SCRIPT = `document.getElementById("btn").addEventListener("click", () => { ... })`;
|
|
128
|
+
* <script {...inlineScript(SCROLL_SCRIPT)} />
|
|
129
|
+
*
|
|
130
|
+
* // With arguments:
|
|
131
|
+
* const INIT_SCRIPT = (id: string) => `document.getElementById("${id}").dataset.ready = "true"`;
|
|
132
|
+
* <script {...inlineScript(INIT_SCRIPT("my-element"))} />
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export function inlineScript(js: string) {
|
|
136
|
+
return { dangerouslySetInnerHTML: { __html: js } } as const;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @deprecated Removed in TanStack Start.
|
|
141
|
+
*
|
|
142
|
+
* The Fresh/Deno HTMX-based partial-section pattern (`useSection` /
|
|
143
|
+
* `usePartialSection` + `sections/Component.tsx`) does not apply on
|
|
144
|
+
* Cloudflare Workers and React. Replace call-sites with one of:
|
|
145
|
+
*
|
|
146
|
+
* 1. Local React state (`useState` + event handlers) for client-side toggles.
|
|
147
|
+
* 2. `createServerFn` + `useMutation` for server actions.
|
|
148
|
+
* 3. Direct `invoke` calls (`~/server/invoke`) for ad-hoc loaders.
|
|
149
|
+
*
|
|
150
|
+
* See: deco-to-tanstack-migration skill, "useComponent / partial sections"
|
|
151
|
+
* section, for the per-pattern recipes.
|
|
152
|
+
*
|
|
153
|
+
* ## SSR-safe stub behavior (since 2.27)
|
|
154
|
+
*
|
|
155
|
+
* Earlier versions threw on call. That broke SSR for the *entire page* if a
|
|
156
|
+
* single section still imported `useSection` — even sections that the user
|
|
157
|
+
* never interacts with (e.g. legacy login form on the homepage). React's
|
|
158
|
+
* error boundary would catch the throw and degrade the whole route to
|
|
159
|
+
* client rendering.
|
|
160
|
+
*
|
|
161
|
+
* The current behavior:
|
|
162
|
+
* - returns a stable placeholder URL (`HTMX_LEGACY_URL`) so it can be
|
|
163
|
+
* embedded in `hx-get` / `hx-post` attributes without crashing the
|
|
164
|
+
* SSR pass,
|
|
165
|
+
* - logs a deduped `console.warn` (in dev only) per call site so the
|
|
166
|
+
* migration signal stays loud,
|
|
167
|
+
* - the `htmx-residue` audit rule still catalogues every call site for
|
|
168
|
+
* systematic rewrite.
|
|
169
|
+
*
|
|
170
|
+
* This is a deliberate trade-off: SSR success > strict-throw enforcement.
|
|
171
|
+
* Audit + skill docs do the enforcement instead.
|
|
172
|
+
*/
|
|
173
|
+
const DEPRECATION_MESSAGE =
|
|
174
|
+
"[@decocms/start] useSection / usePartialSection were removed. " +
|
|
175
|
+
"The Fresh/Deno HTMX partial-section pattern does not apply on " +
|
|
176
|
+
"TanStack Start / Cloudflare Workers. Replace call-sites with " +
|
|
177
|
+
"createServerFn + useMutation, or local React state. See the " +
|
|
178
|
+
"deco-to-tanstack-migration skill for per-pattern recipes. " +
|
|
179
|
+
"Run `deco-post-cleanup` and look for rule [9] htmx-residue to find " +
|
|
180
|
+
"every site call-site that still depends on this.";
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Stable placeholder URL returned by the legacy `useSection` /
|
|
184
|
+
* `usePartialSection` stubs. Hitting this URL surfaces a clear error.
|
|
185
|
+
* Exported so frameworks/tests can match against it.
|
|
186
|
+
*/
|
|
187
|
+
export const HTMX_LEGACY_URL = "/__deco_legacy_htmx_section__";
|
|
188
|
+
|
|
189
|
+
function warnLegacyHtmx(name: string) {
|
|
190
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (typeof (globalThis as any).__DECO_LEGACY_HTMX_WARNED === "undefined") {
|
|
194
|
+
(globalThis as any).__DECO_LEGACY_HTMX_WARNED = new Set<string>();
|
|
195
|
+
}
|
|
196
|
+
const set = (globalThis as any).__DECO_LEGACY_HTMX_WARNED as Set<string>;
|
|
197
|
+
if (set.has(name)) return;
|
|
198
|
+
set.add(name);
|
|
199
|
+
console.warn(`[${name}] ${DEPRECATION_MESSAGE}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function usePartialSection(_props?: Record<string, unknown>): string {
|
|
203
|
+
warnLegacyHtmx("usePartialSection");
|
|
204
|
+
return HTMX_LEGACY_URL;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function useSection(_props?: Record<string, unknown>): string {
|
|
208
|
+
warnLegacyHtmx("useSection");
|
|
209
|
+
return HTMX_LEGACY_URL;
|
|
210
|
+
}
|