Skip to content

Commit

Permalink
feat: Add NextJS app router support (#3074)
Browse files Browse the repository at this point in the history
  • Loading branch information
ntucker authored Jun 6, 2024
1 parent e9048b8 commit 1f1f66a
Show file tree
Hide file tree
Showing 26 changed files with 248 additions and 17 deletions.
32 changes: 32 additions & 0 deletions .changeset/many-rings-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@data-client/ssr': patch
---

Add DataProvider export to /nextjs namespace.

This provides 'App Router' compatibility. Simply add it to the root layout, ensuring
`children` is rendered as a descendent.

<details open>
<summary><b>app/layout.tsx</b></summary>

```tsx
import { DataProvider } from '@data-client/react/nextjs';
import { AsyncBoundary } from '@data-client/react';

export default function RootLayout({ children }) {
return (
<html>
<body>
<DataProvider>
<header>Title</header>
<AsyncBoundary>{children}</AsyncBoundary>
<footer></footer>
</DataProvider>
</body>
</html>
);
}
```

</details>
8 changes: 8 additions & 0 deletions .changeset/metal-birds-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@data-client/use-enhanced-reducer': patch
'@data-client/react': patch
'@data-client/redux': patch
'@data-client/ssr': patch
---

Compatibility with server/client component build rules
24 changes: 20 additions & 4 deletions examples/nextjs/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
Expand All @@ -13,8 +17,20 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
1 change: 1 addition & 0 deletions packages/react/src/components/CacheProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
import {
initialState as defaultState,
NetworkManager,
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
import React from 'react';
import type { JSX } from 'react';

Expand Down
1 change: 1 addition & 0 deletions packages/react/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
import { Controller, initialState } from '@data-client/core';
import type { ActionTypes, State } from '@data-client/core';
import { createContext } from 'react';
Expand Down
12 changes: 8 additions & 4 deletions packages/react/src/hooks/useCacheState.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
'use client';
import type { State } from '@data-client/core';
import React, { useContext } from 'react';
import React from 'react';

import use from './useUniversal.js';
import { StateContext, StoreContext } from '../context.js';

const useCacheState: () => State<unknown> =
/* istanbul ignore if */
(
typeof window === 'undefined' &&
Object.hasOwn(React, 'useSyncExternalStore')
) ?
/* istanbul ignore next */
() => {
const store = useContext(StoreContext);
const state = useContext(StateContext);
const store = use(StoreContext);
const state = use(StateContext);
const syncState = React.useSyncExternalStore(
store.subscribe,
store.getState,
store.getState,
);
return store.uninitialized ? state : syncState;
}
: () => useContext(StateContext);
: () => use(StateContext);

export default useCacheState;
8 changes: 2 additions & 6 deletions packages/react/src/hooks/useController.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
// Server Side Component compatibility (specifying this cannot be used as such)
// context does not work in server components
// https://beta.nextjs.org/docs/rendering/server-and-client-components#third-party-packages
'use client';
import type { Controller } from '@data-client/core';
import { useContext } from 'react';

import use from './useUniversal.js';
import { ControllerContext } from '../context.js';

/**
* Imperative control of Reactive Data Client store
* @see https://dataclient.io/docs/api/useController
*/
export default function useController(): Controller {
return useContext(ControllerContext);
return use(ControllerContext);
}
1 change: 0 additions & 1 deletion packages/react/src/hooks/useDLE.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
FetchFunction,
Schema,
ResolveType,
NI,
} from '@data-client/core';
import { ExpiryStatus } from '@data-client/core';
import { useMemo } from 'react';
Expand Down
1 change: 0 additions & 1 deletion packages/react/src/hooks/useDLE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
FetchFunction,
Schema,
ResolveType,
NI,
} from '@data-client/core';
import { ExpiryStatus } from '@data-client/core';
import { useMemo } from 'react';
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/hooks/useError.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import type { NI, NetworkError, UnknownError } from '@data-client/core';
import type { NetworkError, UnknownError } from '@data-client/core';
import { EndpointInterface } from '@data-client/core';

import useCacheState from './useCacheState.js';
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/hooks/useLive.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
/* eslint-disable @typescript-eslint/ban-ts-comment */
import {
EndpointInterface,
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/hooks/useSubscription.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
import {
EndpointInterface,
Schema,
Expand Down
7 changes: 7 additions & 0 deletions packages/react/src/hooks/useUniversal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import React, { useContext } from 'react';

const useUniversal: <T>(context: React.Context<T>) => T =
/* istanbul ignore if */
'use' in React ? /* istanbul ignore next */ (React.use as any) : useContext;
export default useUniversal;
1 change: 1 addition & 0 deletions packages/redux/src/ExternalCacheProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
import {
State,
ActionTypes,
Expand Down
3 changes: 3 additions & 0 deletions packages/ssr/rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import babel from 'rollup-plugin-babel';
import banner from 'rollup-plugin-banner2';
import commonjs from 'rollup-plugin-commonjs';
import filesize from 'rollup-plugin-filesize';
import json from 'rollup-plugin-json';
Expand Down Expand Up @@ -71,6 +72,8 @@ if (process.env.BROWSERSLIST_ENV !== 'node12') {
replace({ 'process.env.CJS': 'true' }),
resolve({ extensions: nativeExtensions }),
commonjs({ extensions: nativeExtensions }),
// for nextjs 13 compatibility in node https://nextjs.org/docs/app/building-your-application/rendering
banner(() => "'use client';\n"),
],
});
}
Expand Down
1 change: 1 addition & 0 deletions packages/ssr/src/createPersistedStore.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
import {
ExternalCacheProvider,
PromiseifyMiddleware,
Expand Down
44 changes: 44 additions & 0 deletions packages/ssr/src/nextjs/DataProvider/DataProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
'use client';
import { CacheProvider } from '@data-client/react';
import { Suspense, useMemo, type ComponentProps } from 'react';

import { readyContext } from './context.js';
import ServerDataComponent from './ServerDataComponent.js';
import ServerProvider from './ServerProvider.js';
import { getInitialData } from '../../getInitialData.js';

const DataProvider =
typeof window !== 'undefined' ?
({ children, ...props }: ProviderProps) => {
const [initialState, useReady] = useMemo(() => {
const initialState = getInitialData();
return [initialState, () => initialState];
}, []);

return (
<CacheProvider {...props} initialState={initialState}>
<readyContext.Provider value={useReady}>
<Suspense>
<ServerDataComponent />
</Suspense>
{children}
</readyContext.Provider>
</CacheProvider>
);
}
: ({ children, ...props }: ProviderProps) => (
<ServerProvider {...props}>
<Suspense>
<ServerDataComponent />
</Suspense>
{children}
</ServerProvider>
);
export default DataProvider;

type ProviderProps = Omit<
Partial<ComponentProps<typeof CacheProvider>>,
'initialState'
> & {
children: React.ReactNode;
};
12 changes: 12 additions & 0 deletions packages/ssr/src/nextjs/DataProvider/ServerDataComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { use } from 'react';

import { readyContext } from './context.js';
import ServerData from '../../ServerData.js';

const id = 'data-client-data';

const ServerDataComponent = ({ nonce }: { nonce?: string | undefined }) => {
const data = use(readyContext)();
return <ServerData data={data} id={id} nonce={nonce} />;
};
export default ServerDataComponent;
21 changes: 21 additions & 0 deletions packages/ssr/src/nextjs/DataProvider/ServerProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client';
import { type CacheProvider } from '@data-client/react';
import { useMemo, type ComponentProps } from 'react';

import createPersistedStore from './createPersistedStore.js';

export default function ServerProvider({
children,
...props
}: ProviderProps): React.ReactElement {
const [ServerCacheProvider] = useMemo(createPersistedStore, []);

return <ServerCacheProvider {...props}>{children}</ServerCacheProvider>;
}

type ProviderProps = Omit<
Partial<ComponentProps<typeof CacheProvider>>,
'initialState'
> & {
children: React.ReactNode;
};
7 changes: 7 additions & 0 deletions packages/ssr/src/nextjs/DataProvider/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client';
import { type State, __INTERNAL__ } from '@data-client/react';
import { createContext } from 'react';

export const readyContext = createContext(
(): State<unknown> => __INTERNAL__.initialState,
);
70 changes: 70 additions & 0 deletions packages/ssr/src/nextjs/DataProvider/createPersistedStore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';
import {
ExternalCacheProvider,
PromiseifyMiddleware,
Controller,
Manager,
NetworkManager,
State,
__INTERNAL__,
} from '@data-client/redux';
import { useSyncExternalStore } from 'react';
import { createStore, applyMiddleware } from 'redux';

import { readyContext } from './context.js';

const { createReducer, initialState, applyManager } = __INTERNAL__;

export default function createPersistedStore(managers?: Manager[]) {
const controller = new Controller();
managers = managers ?? [new NetworkManager()];
const nm: NetworkManager = managers.find(
m => m instanceof NetworkManager,
) as any;
if (nm === undefined)
throw new Error('managers must include a NetworkManager');
const reducer = createReducer(controller);
const enhancer = applyMiddleware(
// redux 5's types are wrong and do not allow any return typing from next, which is incorrect.
// `next: (action: unknown) => unknown`: allows any action, but disallows all return types.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
...applyManager(managers, controller),
PromiseifyMiddleware,
);
const store = createStore(reducer, initialState as any, enhancer);
managers.forEach(manager => manager.init?.(store.getState()));

const selector = (state: any) => state;

const getState = () => selector(store.getState());
let firstRender = true;
function useReadyCacheState(): State<unknown> {
const inFlightFetches = nm.allSettled();
if (inFlightFetches) {
firstRender = false;
throw inFlightFetches;
}
if (firstRender) {
firstRender = false;
throw new Promise(resolve => setTimeout(resolve, 10));
}

return useSyncExternalStore(store.subscribe, getState, getState);
}

function ServerCacheProvider({ children }: { children: React.ReactNode }) {
return (
<readyContext.Provider value={useReadyCacheState}>
<ExternalCacheProvider
store={store}
selector={selector}
controller={controller}
>
{children}
</ExternalCacheProvider>
</readyContext.Provider>
);
}
return [ServerCacheProvider, controller, store] as const;
}
1 change: 1 addition & 0 deletions packages/ssr/src/nextjs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ Object.hasOwn =
return Object.prototype.hasOwnProperty.call(it, key);
};
export { default as DataClientDocument } from './DataClientDocument.js';
export { default as DataProvider } from './DataProvider/DataProvider.js';
export { default as AppCacheProvider } from './AppCacheProvider.js';
3 changes: 3 additions & 0 deletions packages/use-enhanced-reducer/rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import babel from 'rollup-plugin-babel';
import banner from 'rollup-plugin-banner2';
import commonjs from 'rollup-plugin-commonjs';
import filesize from 'rollup-plugin-filesize';
import json from 'rollup-plugin-json';
Expand Down Expand Up @@ -70,6 +71,8 @@ export default [
}),
resolve({ extensions }),
commonjs({ extensions }),
// for nextjs 13 compatibility in node https://nextjs.org/docs/app/building-your-application/rendering
banner(() => "'use client';\n"),
],
},
];
1 change: 1 addition & 0 deletions packages/use-enhanced-reducer/src/useEnhancedReducer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
import React, {
useReducer,
useMemo,
Expand Down
Loading

0 comments on commit 1f1f66a

Please sign in to comment.