Skip to content

Commit

Permalink
feat: Add support for custom serialization functions (#41)
Browse files Browse the repository at this point in the history
* Add `serializationFunction` and `deserializationFunction` options
* Add internationalized routing to demo project

Co-authored-by: bjoluc <[email protected]>
  • Loading branch information
heinthanth and bjoluc committed Aug 16, 2022
1 parent 170f07b commit 0bae6e9
Show file tree
Hide file tree
Showing 12 changed files with 208 additions and 40 deletions.
19 changes: 18 additions & 1 deletion demo/demo-component.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import Link from "next/link";
import {useRouter} from "next/router";
import React from "react";
import {useSelector} from "react-redux";

import {pageSlice, selectPage, useAppDispatch} from "./store";

export const DemoComponent: React.FC = () => {
const dispatch = useAppDispatch();
const {title, subtitle, counter} = useSelector(selectPage);
const {title, subtitle, counter, locale} = useSelector(selectPage);

const router = useRouter();
const setLocale = async (locale: string) => {
const {pathname, asPath, query} = router;
dispatch(pageSlice.actions.setLocale(locale));
await router.push({pathname, query}, asPath, {locale});
};

return (
<div
Expand All @@ -25,6 +33,15 @@ export const DemoComponent: React.FC = () => {
<button type="button" onClick={() => dispatch(pageSlice.actions.increaseCounter())}>
Increase Counter
</button>
<p>
Locale:{" "}
<select value={locale} onChange={async (event) => setLocale(event.target.value)}>
<option value="en">en</option>
<option value="fr">fr</option>
<option value="nl">nl</option>
</select>
</p>

<div style={{display: "grid", gap: "1em", gridAutoFlow: "column", marginTop: "2.5rem"}}>
<Link href="/ssr/1">SSR Page 1</Link>
<Link href="/ssr/2">SSR Page 2</Link>
Expand Down
4 changes: 4 additions & 0 deletions demo/next.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
module.exports = {
i18n: {
locales: ["en", "fr", "nl"],
defaultLocale: "en",
},
async redirects() {
return [{source: "/", destination: "/ssr/1", permanent: false}];
},
Expand Down
5 changes: 3 additions & 2 deletions demo/pages/ssr/3.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import {NextPage} from "next";
import React from "react";

import {DemoComponent} from "../../demo-component";
import {setTitleWithDelay, wrapper} from "../../store";
import {pageSlice, setTitleWithDelay, wrapper} from "../../store";

const Page: NextPage = (props) => <DemoComponent />;

Page.getInitialProps = wrapper.getInitialPageProps((store) => async () => {
Page.getInitialProps = wrapper.getInitialPageProps((store) => async ({locale}) => {
store.dispatch(pageSlice.actions.setLocale(locale!));
await store.dispatch(setTitleWithDelay(`SSR Page 3`, "via getInitialProps()"));
return {};
});
Expand Down
16 changes: 10 additions & 6 deletions demo/pages/ssr/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ import {InferGetServerSidePropsType, NextPage} from "next";
import React from "react";

import {DemoComponent} from "../../demo-component";
import {setTitleWithDelay, wrapper} from "../../store";
import {pageSlice, setTitleWithDelay, wrapper} from "../../store";

const Page: NextPage<InferGetServerSidePropsType<typeof getServerSideProps>> = (props) => (
<DemoComponent />
);

export const getServerSideProps = wrapper.getServerSideProps((store) => async ({params}) => {
const id = params!.id as string;
export const getServerSideProps = wrapper.getServerSideProps(
(store) =>
async ({params, locale}) => {
const id = params!.id as string;

await store.dispatch(setTitleWithDelay(`SSR Page ${id}`, "via getServerSideProps()"));
store.dispatch(pageSlice.actions.setLocale(locale!));
await store.dispatch(setTitleWithDelay(`SSR Page ${id}`, "via getServerSideProps()"));

return {props: {id}};
});
return {props: {id}};
}
);

export default Page;
12 changes: 8 additions & 4 deletions demo/pages/static/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ import {GetStaticPaths, InferGetStaticPropsType, NextPage} from "next";
import React from "react";

import {DemoComponent} from "../../demo-component";
import {setTitleWithDelay, wrapper} from "../../store";
import {pageSlice, setTitleWithDelay, wrapper} from "../../store";

const Page: NextPage<InferGetStaticPropsType<typeof getStaticProps>> = (props) => <DemoComponent />;

export const getStaticPaths: GetStaticPaths = async () => ({
paths: ["/static/1", "/static/2"],
export const getStaticPaths: GetStaticPaths = async ({locales}) => ({
paths: locales!.flatMap((locale) => [
{params: {id: "1"}, locale},
{params: {id: "2"}, locale},
]),
fallback: false,
});

export const getStaticProps = wrapper.getStaticProps((store) => async ({params}) => {
export const getStaticProps = wrapper.getStaticProps((store) => async ({params, locale}) => {
const id = params!.id as string;

store.dispatch(pageSlice.actions.setLocale(locale!));
await store.dispatch(setTitleWithDelay(`Static Page ${id}`, "via getStaticProps()"));

return {props: {id}};
Expand Down
15 changes: 13 additions & 2 deletions demo/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {useDispatch} from "react-redux";
export const pageSlice = createSlice({
name: "page",

initialState: {title: "", subtitle: "", counter: 0},
initialState: {title: "", subtitle: "", counter: 0, locale: "en"},

reducers: {
increaseCounter(state) {
Expand All @@ -24,6 +24,9 @@ export const pageSlice = createSlice({
state.counter += 1;
Object.assign(state, payload);
},
setLocale(state, {payload}: PayloadAction<string>) {
state.locale = payload;
},
},

extraReducers: {
Expand Down Expand Up @@ -51,7 +54,15 @@ const makeStore = wrapMakeStore(() =>
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(
nextReduxCookieMiddleware({
subtrees: [`${pageSlice.name}.counter`],
subtrees: [
`${pageSlice.name}.counter`,
{
subtree: `${pageSlice.name}.locale`,
cookieName: "NEXT_LOCALE",
serializationFunction: String,
deserializationFunction: String,
},
],
})
),
})
Expand Down
33 changes: 29 additions & 4 deletions main/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable n/no-unsupported-features/es-syntax */
import {CookieSerializeOptions} from "cookie";
import {Except, SetRequired} from "type-fest";

Expand Down Expand Up @@ -37,9 +38,24 @@ export interface SubtreeConfig extends CookieOptions {
cookieName?: string;

/**
* Whether or not to compress cookie values using lz-string. Defaults to `true`.
* Whether or not to compress cookie values using lz-string. Defaults to `true` if
* {@link SubtreeConfig.serializationFunction} and {@link SubtreeConfig.deserializationFunction}
* have not been specified, and `false` otherwise.
*/
compress?: boolean;

/**
* A function that serializes subtree state into a string. Defaults to `JSON.stringify`.
*
* @note If you set this, make sure to also set the {@link SubtreeConfig.deserializationFunction} option accordingly.
*/
serializationFunction?: (state: any) => string;

/**
* A function that parses a string created by {@link SubtreeConfig.serializationFunction} and returns the
* corresponding subtree state. Defaults to `JSON.parse`.
*/
deserializationFunction?: (state: string) => any;
}

export interface InternalSubtreeConfig
Expand Down Expand Up @@ -91,7 +107,6 @@ export function processMiddlewareConfig(
// Set defaults and destructure the config object
const {subtrees, ...globalSubtreeConfig} = {
ignoreStateFromStaticProps: true,
compress: true,
path: "/",
sameSite: true,
...config,
Expand All @@ -106,16 +121,26 @@ export function processMiddlewareConfig(
current = {subtree: current};
}

const {ignoreStateFromStaticProps, compress, subtree, cookieName, ...cookieOptions} = {
const {
ignoreStateFromStaticProps,
compress,
subtree,
cookieName,
serializationFunction,
deserializationFunction,
...cookieOptions
} = {
...globalSubtreeConfig,
cookieName: current.subtree,
...current,
};
return {
ignoreStateFromStaticProps,
compress,
compress: compress ?? !(serializationFunction ?? deserializationFunction),
subtree,
cookieName,
serializationFunction,
deserializationFunction,
cookieOptions,
};
})
Expand Down
51 changes: 31 additions & 20 deletions main/src/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,44 @@
/* eslint-disable n/no-unsupported-features/es-syntax */ // https://github.com/xojs/xo/issues/598

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

import {InternalSubtreeConfig} from "./config";
import {isClient} from "./util";

export type Cookies = Record<string, JsonValue>;
export type Cookies = Record<string, any>;

export type CookieContext = SetRequired<
Partial<GetServerSidePropsContext | NextPageContext>,
"req" | "res"
>;

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

/**
* An isomorphic class to set and get (compressed) state cookies.
* An isomorphic class to set and get (compressed) state cookies according to a set of
* {@link CookieConfig} objects.
*/
export class StateCookies {
private static _encodeState(state: any) {
return encodeURIComponent(JSON.stringify(state));
}
private static _encodeState(state: any, {compress, serializationFunction}: CookieConfig): string {
const serializedState = (serializationFunction ?? JSON.stringify)(state);

private static _encodeStateCompressed(state: any) {
return compressToEncodedURIComponent(JSON.stringify(state));
return (compress ? compressToEncodedURIComponent : encodeURIComponent)(serializedState);
}

private static _decodeState(state: string, compressed: boolean): JsonValue {
return JSON.parse(
(compressed ? decompressFromEncodedURIComponent : decodeURIComponent)(state)!
) as JsonValue;
private static _decodeState(
state: string,
{compress, deserializationFunction}: CookieConfig
): any {
const decodedState = (compress ? decompressFromEncodedURIComponent : decodeURIComponent)(
state
)!;
return (deserializationFunction ?? JSON.parse)(decodedState);
}

protected _config = new Map<string, CookieConfig>();
Expand Down Expand Up @@ -61,9 +69,10 @@ export class StateCookies {
for (const [name, value] of Object.entries(
parseCookies(this._context, {decode: (value: string) => value})
)) {
const config = this._config.get(name);
if (config) {
this._cookies[name] = StateCookies._decodeState(value, config.compress);
const cookieConfig = this._config.get(name);
if (cookieConfig) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this._cookies[name] = StateCookies._decodeState(value, cookieConfig);
}
}
}
Expand All @@ -72,10 +81,12 @@ export class StateCookies {
}

public set(name: string, state: any) {
const {cookieOptions, compress} = this._config.get(name)!;
setCookie(this._context, name, state, {
...cookieOptions,
encode: compress ? StateCookies._encodeStateCompressed : StateCookies._encodeState,
const cookieConfig = this._config.get(name)!;
const encodedState = StateCookies._encodeState(state, cookieConfig);

setCookie(this._context, name, encodedState, {
...cookieConfig.cookieOptions,
encode: String,
httpOnly: false,
});
}
Expand Down
4 changes: 3 additions & 1 deletion main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const wrapMakeStore =
* A Redux middleware that syncs user-defined subtrees of the Redux state with cookies – on the
* server and on the client. One cookie is used per state subtree and the serialized state is, by
* default, compressed using [lz-string](https://github.com/pieroxy/lz-string). The subtree paths,
* cookie names, cookie options, and compression are configured via a
* cookie names, cookie options, serialization, and compression are configured via a
* {@link NextReduxCookieMiddlewareConfig} object.
*/
export const nextReduxCookieMiddleware: (config: NextReduxCookieMiddlewareConfig) => Middleware =
Expand Down Expand Up @@ -90,6 +90,7 @@ export const nextReduxCookieMiddleware: (config: NextReduxCookieMiddlewareConfig
// Console.log("Triggering initial HYDRATE");
store.dispatch({
type: HYDRATE,
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
payload: walkState(subtrees, (subtree) => allCookies[subtree.cookieName], {}),
});

Expand All @@ -112,6 +113,7 @@ export const nextReduxCookieMiddleware: (config: NextReduxCookieMiddlewareConfig
// getStaticProps, the cookies have remained unchanged and hence the server's
// state is ignored.

// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return allCookies[cookieName];
}
},
Expand Down
8 changes: 8 additions & 0 deletions main/test/__snapshots__/config.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ Object {
"sameSite": false,
"secure": true,
},
"deserializationFunction": undefined,
"ignoreStateFromStaticProps": false,
"serializationFunction": undefined,
"subtree": "path1",
}
`;
Expand All @@ -23,7 +25,9 @@ Object {
"sameSite": false,
"secure": true,
},
"deserializationFunction": undefined,
"ignoreStateFromStaticProps": false,
"serializationFunction": undefined,
"subtree": "path2",
}
`;
Expand All @@ -37,7 +41,9 @@ Object {
"path": "/",
"sameSite": true,
},
"deserializationFunction": undefined,
"ignoreStateFromStaticProps": true,
"serializationFunction": undefined,
"subtree": "path1",
},
"1": Object {
Expand All @@ -47,7 +53,9 @@ Object {
"path": "/",
"sameSite": true,
},
"deserializationFunction": undefined,
"ignoreStateFromStaticProps": true,
"serializationFunction": undefined,
"subtree": "path2",
},
}
Expand Down
Loading

0 comments on commit 0bae6e9

Please sign in to comment.