Skip to content

Commit

Permalink
feat: Add defaultState option to remove a cookie for a specified de…
Browse files Browse the repository at this point in the history
…fault state

Discussed in #17

Closes #17
  • Loading branch information
bjoluc committed Aug 17, 2022
1 parent 0bae6e9 commit 9f0c224
Show file tree
Hide file tree
Showing 9 changed files with 117 additions and 21 deletions.
1 change: 1 addition & 0 deletions demo/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const makeStore = wrapMakeStore(() =>
cookieName: "NEXT_LOCALE",
serializationFunction: String,
deserializationFunction: String,
defaultState: pageSlice.getInitialState().locale,
},
],
})
Expand Down
25 changes: 21 additions & 4 deletions main/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ export interface SubtreeConfig extends CookieOptions {
*/
cookieName?: string;

/**
* If this option is set and no cookie exists for the subtree, the subtree's state will be set to
* the provided `defaultState`. Similarly, if the subtree's state becomes the provided
* `defaultState` and a cookie exists for the subtree, the cookie will be deleted.
*
* An example use case for the `defaultState` option are authorization cookies that are created
* when a client logs in, and removed when the auth-related state is reset on logout.
*
* @note `defaultState` masks the subtree's initial Redux state, i.e., when `defaultState` differs
* from the initial state, the initial state will always be replaced by `defaultState`. Hence,
* when you provide a `defaultState`, it is recommended that it equals the subtree's initial Redux
* state.
*/
defaultState?: unknown;

/**
* Whether or not to compress cookie values using lz-string. Defaults to `true` if
* {@link SubtreeConfig.serializationFunction} and {@link SubtreeConfig.deserializationFunction}
Expand Down Expand Up @@ -122,10 +137,11 @@ export function processMiddlewareConfig(
}

const {
ignoreStateFromStaticProps,
compress,
subtree,
ignoreStateFromStaticProps,
cookieName,
defaultState,
compress,
serializationFunction,
deserializationFunction,
...cookieOptions
Expand All @@ -135,10 +151,11 @@ export function processMiddlewareConfig(
...current,
};
return {
ignoreStateFromStaticProps,
compress: compress ?? !(serializationFunction ?? deserializationFunction),
subtree,
ignoreStateFromStaticProps,
cookieName,
defaultState,
compress: compress ?? !(serializationFunction ?? deserializationFunction),
serializationFunction,
deserializationFunction,
cookieOptions,
Expand Down
33 changes: 25 additions & 8 deletions main/src/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import {compressToEncodedURIComponent, decompressFromEncodedURIComponent} from "lz-string";
import {GetServerSidePropsContext, NextPageContext} from "next";
import {parseCookies, setCookie} from "nookies";
import {destroyCookie, parseCookies, setCookie} from "nookies";
import {SetRequired} from "type-fest";

import {InternalSubtreeConfig} from "./config";
Expand All @@ -17,7 +17,12 @@ export type CookieContext = SetRequired<

export type CookieConfig = Pick<
InternalSubtreeConfig,
"cookieName" | "compress" | "cookieOptions" | "serializationFunction" | "deserializationFunction"
| "cookieName"
| "defaultState"
| "compress"
| "cookieOptions"
| "serializationFunction"
| "deserializationFunction"
>;

/**
Expand Down Expand Up @@ -66,13 +71,17 @@ export class StateCookies {
// Parse cookies if they have not been parsed, always re-parse cookies on the client
if (typeof this._cookies === "undefined" || isClient()) {
this._cookies = {};
for (const [name, value] of Object.entries(
parseCookies(this._context, {decode: (value: string) => value})
)) {
const cookieConfig = this._config.get(name);
if (cookieConfig) {
const allCookies = parseCookies(this._context, {decode: String});

for (const [cookieName, cookieConfig] of this._config.entries()) {
if (typeof allCookies[cookieName] !== "undefined") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this._cookies[name] = StateCookies._decodeState(value, cookieConfig);
this._cookies[cookieName] = StateCookies._decodeState(
allCookies[cookieName],
cookieConfig
);
} else if (typeof cookieConfig.defaultState !== "undefined") {
this._cookies[cookieName] = cookieConfig.defaultState;
}
}
}
Expand All @@ -90,4 +99,12 @@ export class StateCookies {
httpOnly: false,
});
}

public delete(name: string) {
const {cookieOptions} = this._config.get(name)!;
destroyCookie(this._context, name, {
...cookieOptions,
httpOnly: false,
});
}
}
12 changes: 10 additions & 2 deletions main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,17 @@ export const nextReduxCookieMiddleware: (config: NextReduxCookieMiddlewareConfig
if (cookies) {
walkState(
subtrees,
({cookieName}, oldSubtreeState, newSubtreeState) => {
({cookieName, defaultState}, oldSubtreeState, newSubtreeState) => {
if (!isEqual(oldSubtreeState, newSubtreeState)) {
cookies.set(cookieName, newSubtreeState);
// Subtree state has changed
if (
typeof defaultState !== "undefined" &&
isEqual(newSubtreeState, defaultState)
) {
cookies.delete(cookieName);
} else {
cookies.set(cookieName, newSubtreeState);
}
}
},
oldState,
Expand Down
2 changes: 1 addition & 1 deletion main/src/state-walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const walkState = <State extends JsonObject>(
stateA: State,
stateB?: State
) =>
// The following TS error is only reported when testing via TSDX, hence not using ts-expect-error here
// The following TS error is only reported when testing, hence not using ts-expect-error here
// eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
// @ts-ignore https://github.com/immerjs/immer/issues/839
produce(stateA, (draftState) => {
Expand Down
2 changes: 2 additions & 0 deletions main/test/__snapshots__/config.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Object {
"sameSite": false,
"secure": true,
},
"defaultState": undefined,
"deserializationFunction": undefined,
"ignoreStateFromStaticProps": false,
"serializationFunction": undefined,
Expand All @@ -25,6 +26,7 @@ Object {
"sameSite": false,
"secure": true,
},
"defaultState": undefined,
"deserializationFunction": undefined,
"ignoreStateFromStaticProps": false,
"serializationFunction": undefined,
Expand Down
20 changes: 17 additions & 3 deletions main/test/cookies.client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {destroyCookie} from "nookies";
import {StateCookies} from "../src/cookies";

describe("StateCookies on the client", () => {
it("should be able to set and get cookies", () => {
it("should be able to set, get, and delete cookies", () => {
const cookies = new StateCookies();
cookies.setConfigurations([
{cookieName: "cookie1", compress: true, cookieOptions: {}},
Expand All @@ -24,8 +24,10 @@ describe("StateCookies on the client", () => {
cookies.set("cookie2", cookie2);
expect(cookies.getAll()).toEqual({cookie1, cookie2});

destroyCookie(null, "cookie1");
destroyCookie(null, "cookie2");
cookies.delete("cookie2");
expect(cookies.getAll()).toEqual({cookie1});

cookies.delete("cookie1");
expect(cookies.getAll()).toEqual({});
});

Expand Down Expand Up @@ -68,4 +70,16 @@ describe("StateCookies on the client", () => {
deserializationFunction.mockClear();
}
});

it("should respect a cookie's `defaultState` option", () => {
const cookies = new StateCookies();
cookies.setConfigurations([
{cookieName: "myCookie", compress: true, defaultState: "default", cookieOptions: {}},
]);

expect(cookies.getAll()).toEqual({myCookie: "default"});

cookies.set("myCookie", "custom");
expect(cookies.getAll()).toEqual({myCookie: "custom"});
});
});
21 changes: 18 additions & 3 deletions main/test/cookies.server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {CookieContext, StateCookies} from "../src/cookies";

export function parseSetCookieHeaders(response: ServerResponse) {
const headers = response.getHeader("set-cookie") as string | string[];

return Object.fromEntries(
parse(headers, {decodeValues: false}).map((cookie) => [cookie.name, cookie.value])
Object.entries(parse(headers, {map: true, decodeValues: false}))
.filter(([, cookie]) => cookie.maxAge !== -1)
.map(([key, cookie]) => [key, cookie.value])
);
}

Expand All @@ -27,18 +28,23 @@ describe("StateCookies on the server", () => {
context = createMocks();
});

it("should be able to set and get cookies", () => {
it("should be able to set, get, and delete cookies", () => {
const cookies = new StateCookies(context);
cookies.setConfigurations([
{cookieName: "cookie1", compress: true, cookieOptions: {}},
{cookieName: "cookie2", compress: false, cookieOptions: {path: "/"}},
{cookieName: "cookie3", compress: true, cookieOptions: {}},
]);

const cookie1 = {my: {fancy: "state"}};
const cookie2 = {second: "state"};
cookies.set("cookie1", cookie1);
cookies.set("cookie2", cookie2);

const cookie3 = {second: "state"};
cookies.set("cookie3", cookie3);
cookies.delete("cookie3");

// Parse the set-cookie header from the response
const parsedCookies = parseSetCookieHeaders(context.res);
expect(parsedCookies).toEqual({
Expand All @@ -56,4 +62,13 @@ describe("StateCookies on the server", () => {
// object (request cookies do not change)
expect(cookies.getAll()).toBe(retrievedCookies);
});

it("should respect a cookie's `defaultState` option", () => {
const cookies = new StateCookies();
cookies.setConfigurations([
{cookieName: "myCookie", compress: true, defaultState: "default", cookieOptions: {}},
]);

expect(cookies.getAll()).toEqual({myCookie: "default"});
});
});
22 changes: 22 additions & 0 deletions main/test/index.client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,26 @@ describe("nextReduxCookieMiddleware() on the client", () => {
["cookie2", "incoming2"],
]);
});

it("should delete state cookies that match their `defaultState`s", () => {
const {next, invoke, setState} = createMiddlewareTestFunctions({
subtrees: [{subtree: "mySubtree", cookieName: "myCookie", defaultState: {the: "default"}}],
});
const stateCookies = getStateCookiesInstance();

setState({mySubtree: {custom: "state"}});

// Simulate an action that resets the state to the default state
next.mockImplementationOnce(() => {
setState({mySubtree: {the: "default"}});
});
invoke({type: "some-action"});

// The middleware should not have set myCookie
expect(stateCookies.set).toHaveBeenCalledTimes(0);

// Instead, myCookie should have been deleted
expect(stateCookies.delete).toHaveBeenCalledTimes(1);
expect(stateCookies.delete).toHaveBeenCalledWith("myCookie");
});
});

0 comments on commit 9f0c224

Please sign in to comment.