diff --git a/.changeset/cookies-solid2-migration.md b/.changeset/cookies-solid2-migration.md new file mode 100644 index 000000000..59bf1d3f5 --- /dev/null +++ b/.changeset/cookies-solid2-migration.md @@ -0,0 +1,16 @@ +--- +"@solid-primitives/cookies": major +--- + +Migrate to Solid.js v2.0 (beta.13) + +## Breaking Changes + +**Peer dependencies**: `solid-js@^2.0.0-beta.13` and `@solidjs/web@^2.0.0-beta.13` are now required. + +- `isServer` and `getRequestEvent` are now imported from `@solidjs/web` (were `solid-js/web`) +- `createEffect` follows the split compute/apply pattern required by Solid 2.0 — the internal cookie-sync effect now separates reactive tracking from the `document.cookie` write + +## New + +- Full test suite added: 19 browser tests and 6 SSR tests covering `parseCookie`, `getCookiesString`, `createServerCookie`, and `createUserTheme` diff --git a/packages/cookies/README.md b/packages/cookies/README.md index 79c8e82f9..29ffc258e 100644 --- a/packages/cookies/README.md +++ b/packages/cookies/README.md @@ -8,11 +8,15 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/cookies?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/cookies) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -A set of primitives for handling cookies in solid +Reactive, signal-based cookie primitives for isomorphic use — readable and writable on both the server and the client. -- [`createServerCookie`](#createservercookie) - Provides a getter and setter for a reactive cookie, which works isomorphically. -- [`createUserTheme`](#createusertheme) - Creates a Server Cookie providing a type safe way to store a theme and access it on the server or client. -- [`getCookiesString`](#getCookiesString) - A primitive that allows for the cookie string to be accessed isomorphically on the client, or on the server +This package provides **higher-order** cookie functionality: typed reactive signals that stay in sync with `document.cookie`, with built-in SSR support via `getRequestEvent`. It is intentionally narrow in scope. + +> **Looking for raw cookie read/write?** [`@solid-primitives/storage`](../storage) exposes `cookieStorage` — a `localStorage`-compatible API (`getItem`, `setItem`, `removeItem`, `key`) that works on both client and server, including full support for cookie attributes such as `domain`, `path`, `secure`, `sameSite`, and `maxAge`. Use that package when you need direct, imperative access to the cookie store. + +- [`createServerCookie`](#createservercookie) - Reactive signal backed by a named cookie; isomorphic on client and server. +- [`createUserTheme`](#createusertheme) - Type-safe `"light" | "dark"` theme signal stored as a cookie. +- [`getCookiesString`](#getcookiesstring) - Returns the raw cookie string from `document.cookie` on the client or the request `Cookie` header on the server. ## Installation @@ -24,11 +28,13 @@ yarn add @solid-primitives/cookies pnpm add @solid-primitives/cookies ``` +Requires `solid-js@^2.0.0-beta.13` and `@solidjs/web@^2.0.0-beta.13` as peer dependencies. + ## How to use it ## `createServerCookie` -A primitive for creating a cookie that can be accessed isomorphically on the client, or the server. +Creates a reactive signal whose value is persisted to a named cookie. Reading the signal works on both the server (via the request `Cookie` header) and the client (via `document.cookie`). Writing the signal on the client automatically syncs the new value back to `document.cookie`. ```ts import { createServerCookie } from "@solid-primitives/cookies"; @@ -36,18 +42,19 @@ import { createServerCookie } from "@solid-primitives/cookies"; const [cookie, setCookie] = createServerCookie("cookieName"); cookie(); // => string | undefined +setCookie("newValue"); ``` ### Custom serialization -Custom cookie serializers and deserializers can also be implemented +Pass `deserialize` and `serialize` functions to store non-string values. ```ts import { createServerCookie } from "@solid-primitives/cookies"; const [serverCookie, setServerCookie] = createServerCookie("coolCookie", { deserialize: str => (str ? str.split(" ") : []), // Deserializes cookie into a string[] - serialize: val => (val ? val.join(" ") : ""), // serializes the value back into a string + serialize: val => (val ? val.join(" ") : ""), // Serializes the value back into a string }); serverCookie(); // => string[] @@ -55,7 +62,7 @@ serverCookie(); // => string[] ## `createUserTheme` -Composes `createServerCookie` to provide a type safe way to store a theme and access it on the server or client. +Composes `createServerCookie` to provide a type-safe `"light" | "dark"` theme signal. Any unrecognized cookie value is treated as `undefined` (or the provided `defaultValue`). ```ts import { createUserTheme } from "@solid-primitives/cookies"; @@ -74,8 +81,7 @@ theme(); // => "light" | "dark" ## `getCookiesString` -A primitive that allows for the cookie string to be accessed isomorphically on the client, or on the server. -Uses `getRequestEvent` on the server and `document.cookie` on the client. +Returns the raw cookie string for the current environment — `document.cookie` in the browser, or the `Cookie` request header on the server. Use it together with `parseCookie` when you need to read a cookie value outside of a reactive context. ```ts import { getCookiesString, parseCookie } from "@solid-primitives/cookies"; diff --git a/packages/cookies/package.json b/packages/cookies/package.json index bb4f72737..f91767a15 100644 --- a/packages/cookies/package.json +++ b/packages/cookies/package.json @@ -1,7 +1,7 @@ { "name": "@solid-primitives/cookies", "version": "0.0.3", - "description": "A set of primitives for handling cookies in solid", + "description": "Reactive, signal-based cookie primitives for isomorphic use on client and server", "author": "Thomas Beer (https://github.com/Tommypop2)", "contributors": [ "Damian Tarnawski ", @@ -56,9 +56,11 @@ "test:ssr": "pnpm run vitest --mode ssr" }, "peerDependencies": { - "solid-js": "^1.8.18" + "@solidjs/web": "^2.0.0-beta.13", + "solid-js": "^2.0.0-beta.13" }, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.13", + "solid-js": "2.0.0-beta.13" } } diff --git a/packages/cookies/src/index.ts b/packages/cookies/src/index.ts index 301e14fea..9bff41a8e 100644 --- a/packages/cookies/src/index.ts +++ b/packages/cookies/src/index.ts @@ -1,5 +1,5 @@ import { createSignal, createEffect, type Signal } from "solid-js"; -import { getRequestEvent, isServer } from "solid-js/web"; +import { getRequestEvent, isServer } from "@solidjs/web"; const YEAR = 365 * 24 * 60 * 60; @@ -66,13 +66,16 @@ export function createServerCookie( cookieMaxAge = YEAR, } = options ?? {}; - const [cookie, setCookie] = createSignal(deserialize(parseCookie(getCookiesString(), name))); + const [cookie, setCookie] = createSignal( + deserialize(parseCookie(getCookiesString(), name)) as Exclude, + ); - createEffect(p => { - const string = serialize(cookie()); - if (p !== string) document.cookie = `${name}=${string};max-age=${cookieMaxAge}`; - return string; - }); + createEffect( + () => serialize(cookie()), + (string, prev) => { + if (prev !== string) document.cookie = `${name}=${string};max-age=${cookieMaxAge}`; + }, + ); return [cookie, setCookie]; } diff --git a/packages/cookies/src/test/index.test.ts b/packages/cookies/src/test/index.test.ts new file mode 100644 index 000000000..e0a63e9d2 --- /dev/null +++ b/packages/cookies/src/test/index.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createRoot, flush } from "solid-js"; +import { parseCookie, getCookiesString, createServerCookie, createUserTheme } from "../src/index.js"; + +beforeEach(() => { + document.cookie.split(";").forEach(c => { + const key = c.split("=")[0]!.trim(); + if (key) document.cookie = `${key}=;max-age=0`; + }); +}); + +// ── parseCookie ─────────────────────────────────────────────────────────────── + +describe("parseCookie", () => { + it("extracts a value by key", () => { + expect(parseCookie("foo=bar", "foo")).toBe("bar"); + }); + + it("extracts a value from multiple cookies", () => { + expect(parseCookie("foo=bar; baz=qux", "baz")).toBe("qux"); + }); + + it("returns undefined for a missing key", () => { + expect(parseCookie("foo=bar", "missing")).toBeUndefined(); + }); + + it("returns undefined for an empty cookie string", () => { + expect(parseCookie("", "foo")).toBeUndefined(); + }); +}); + +// ── getCookiesString ────────────────────────────────────────────────────────── + +describe("getCookiesString", () => { + it("returns document.cookie on the client", () => { + document.cookie = "ck_test=hello"; + expect(getCookiesString()).toContain("ck_test=hello"); + }); +}); + +// ── createServerCookie ──────────────────────────────────────────────────────── + +describe("createServerCookie", () => { + it("initializes signal from the current cookie value", () => { + document.cookie = "ck_init=world"; + const dispose = createRoot(dispose => { + const [cookie] = createServerCookie("ck_init"); + expect(cookie()).toBe("world"); + return dispose; + }); + dispose(); + }); + + it("initializes to undefined when the cookie is absent", () => { + const dispose = createRoot(dispose => { + const [cookie] = createServerCookie("ck_absent"); + expect(cookie()).toBeUndefined(); + return dispose; + }); + dispose(); + }); + + it("setter updates the signal value", () => { + document.cookie = "ck_set=before"; + const { setCookie, dispose } = createRoot(dispose => { + const [, setCookie] = createServerCookie("ck_set"); + return { setCookie, dispose }; + }); + flush(); + setCookie("after"); + flush(); + expect(parseCookie(document.cookie, "ck_set")).toBe("after"); + dispose(); + }); + + it("writes to document.cookie when value changes", () => { + document.cookie = "ck_write=old"; + const { setCookie, dispose } = createRoot(dispose => { + const [, setCookie] = createServerCookie("ck_write"); + return { setCookie, dispose }; + }); + flush(); + setCookie("new"); + flush(); + expect(parseCookie(document.cookie, "ck_write")).toBe("new"); + dispose(); + }); + + it("does not overwrite the cookie when the serialized value is unchanged", () => { + document.cookie = "ck_same=abc"; + const { cookie, setCookie, dispose } = createRoot(dispose => { + const [cookie, setCookie] = createServerCookie("ck_same"); + return { cookie, setCookie, dispose }; + }); + flush(); + setCookie("abc"); + flush(); + expect(cookie()).toBe("abc"); + expect(parseCookie(document.cookie, "ck_same")).toBe("abc"); + dispose(); + }); + + it("applies a custom deserialize function", () => { + document.cookie = "ck_deser=42"; + const dispose = createRoot(dispose => { + const [count] = createServerCookie("ck_deser", { + deserialize: str => (str !== undefined ? parseInt(str, 10) : 0), + serialize: String, + }); + expect(count()).toBe(42); + return dispose; + }); + dispose(); + }); + + it("applies a custom serialize function", () => { + const { setTags, dispose } = createRoot(dispose => { + const [, setTags] = createServerCookie("ck_ser", { + deserialize: str => (str ? str.split(",") : []), + serialize: val => val.join(","), + }); + return { setTags, dispose }; + }); + flush(); + setTags(["a", "b", "c"]); + flush(); + expect(parseCookie(document.cookie, "ck_ser")).toBe("a,b,c"); + dispose(); + }); + + it("respects a custom cookieMaxAge option", () => { + const { setCookie, dispose } = createRoot(dispose => { + const [, setCookie] = createServerCookie("ck_age", { cookieMaxAge: 60 }); + return { setCookie, dispose }; + }); + flush(); + setCookie("x"); + flush(); + expect(parseCookie(document.cookie, "ck_age")).toBe("x"); + dispose(); + }); +}); + +// ── createUserTheme ─────────────────────────────────────────────────────────── + +describe("createUserTheme", () => { + it("reads a stored theme from the cookie", () => { + document.cookie = "ck_theme=dark"; + const dispose = createRoot(dispose => { + const [theme] = createUserTheme("ck_theme"); + expect(theme()).toBe("dark"); + return dispose; + }); + dispose(); + }); + + it("returns undefined for an unrecognized theme value", () => { + document.cookie = "ck_theme2=blue"; + const dispose = createRoot(dispose => { + const [theme] = createUserTheme("ck_theme2"); + expect(theme()).toBeUndefined(); + return dispose; + }); + dispose(); + }); + + it("returns undefined when no cookie is set and no default is provided", () => { + const dispose = createRoot(dispose => { + const [theme] = createUserTheme("ck_theme3"); + expect(theme()).toBeUndefined(); + return dispose; + }); + dispose(); + }); + + it("uses the defaultValue when cookie is absent", () => { + const dispose = createRoot(dispose => { + const [theme] = createUserTheme("ck_theme4", { defaultValue: "light" }); + expect(theme()).toBe("light"); + return dispose; + }); + dispose(); + }); + + it("accepts 'light' and 'dark' as valid values", () => { + document.cookie = "ck_theme5=light"; + const dispose = createRoot(dispose => { + const [theme] = createUserTheme("ck_theme5"); + expect(theme()).toBe("light"); + return dispose; + }); + dispose(); + }); + + it("persists a theme change to document.cookie", () => { + const { setTheme, dispose } = createRoot(dispose => { + const [, setTheme] = createUserTheme("ck_theme6"); + return { setTheme, dispose }; + }); + flush(); + setTheme("dark"); + flush(); + expect(parseCookie(document.cookie, "ck_theme6")).toBe("dark"); + dispose(); + }); +}); diff --git a/packages/cookies/src/test/server.test.ts b/packages/cookies/src/test/server.test.ts new file mode 100644 index 000000000..f6b25fde8 --- /dev/null +++ b/packages/cookies/src/test/server.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { createRoot } from "solid-js"; +import { getCookiesString, createServerCookie, createUserTheme } from "../src/index.js"; + +describe("SSR", () => { + it("getCookiesString returns a string without a request event", () => { + expect(typeof getCookiesString()).toBe("string"); + }); + + it("getCookiesString returns an empty string when there is no request event", () => { + expect(getCookiesString()).toBe(""); + }); + + it("createServerCookie initializes to undefined when no cookie header is present", () => + createRoot(dispose => { + const [cookie] = createServerCookie("ssr_test"); + expect(cookie()).toBeUndefined(); + dispose(); + })); + + it("createServerCookie does not throw on the server", () => + createRoot(dispose => { + expect(() => createServerCookie("ssr_safe")).not.toThrow(); + dispose(); + })); + + it("createUserTheme returns undefined on the server without a cookie header", () => + createRoot(dispose => { + const [theme] = createUserTheme("ssr_theme"); + expect(theme()).toBeUndefined(); + dispose(); + })); + + it("createUserTheme returns defaultValue on the server when no cookie header is present", () => + createRoot(dispose => { + const [theme] = createUserTheme("ssr_theme2", { defaultValue: "light" }); + expect(theme()).toBe("light"); + dispose(); + })); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7a083eac..dbb4e8d76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,9 +223,12 @@ importers: packages/cookies: devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.13 + version: 2.0.0-beta.13(solid-js@2.0.0-beta.13) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.13 + version: 2.0.0-beta.13 packages/cursor: dependencies: