Skip to content

Commit 07608df

Browse files
Merge pull request #11 from effector/expose-client-scope
Expose getClientScope for dev-tools usage
2 parents 0e2deea + cca86f9 commit 07608df

File tree

7 files changed

+136
-50
lines changed

7 files changed

+136
-50
lines changed

README.md

Lines changed: 78 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> **⚠️ THIS PROJECT IS IN EARLY DEVELOPMENT AND IS NOT STABLE YET ⚠️**
44
5-
This is minimal compatibility layer for effector + Next.js - it only provides one special `EffectorNext` provider component, which allows to fully leverage effector's Fork API, while handling some *special* parts of Next.js SSR and SSG flow.
5+
This is minimal compatibility layer for effector + Next.js - it only provides one special `EffectorNext` provider component, which allows to fully leverage effector's Fork API, while handling some _special_ parts of Next.js SSR and SSG flow.
66

77
So far there are no plans to extend the API, e.g., towards better DX - there are already packages like [`nextjs-effector`](https://github.com/risenforces/nextjs-effector).
88
This package aims only at technical nuances.
@@ -45,7 +45,7 @@ Sid's are added automatically via either built-in babel plugin or our experiment
4545
Add provider to the `pages/_app.tsx` and provide it with server-side `values`
4646

4747
```tsx
48-
import { EffectorNext } from "@effector/next"
48+
import { EffectorNext } from "@effector/next";
4949

5050
export default function App({ Component, pageProps }: AppProps) {
5151
return (
@@ -67,9 +67,9 @@ Notice, that `EffectorNext` should get serialized scope values via props.
6767
Start your computations in server handlers using Fork API
6868

6969
```ts
70-
import { fork, allSettled, serialize } from "effector"
70+
import { fork, allSettled, serialize } from "effector";
7171

72-
import { pageStarted } from "../src/my-page-model"
72+
import { pageStarted } from "../src/my-page-model";
7373

7474
export async function getStaticProps() {
7575
const scope = fork();
@@ -82,13 +82,56 @@ export async function getStaticProps() {
8282
values: serialize(scope),
8383
},
8484
};
85-
};
85+
}
8686
```
8787

8888
Notice, that serialized scope values are provided via the same page prop, which is used in the `_app` for values in `EffectorNext`.
8989

9090
You're all set. Just use effector's units anywhere in components code via `useUnit` from `effector-react`.
9191

92+
### Dev-Tools integration
93+
94+
Most of `effector` dev-tools options require direct access to the `scope` of the app.
95+
At the client you can get current scope via `getClientScope` function, which will return `Scope` in the browser and `null` at the server.
96+
97+
Example of `@effector/redux-devtools-adapter` integration
98+
99+
```tsx
100+
import type { AppProps } from "next/app";
101+
import { EffectorNext, getClientScope } from "@effector/next";
102+
import { attachReduxDevTools } from "@effector/redux-devtools-adapter";
103+
104+
const clientScope = getClientScope();
105+
106+
if (clientScope) {
107+
/**
108+
* Notice, that we need to check for the client scope first
109+
*
110+
* It will be `null` at the server
111+
*/
112+
attachReduxDevTools({
113+
scope: clientScope,
114+
name: "playground-app",
115+
trace: true,
116+
});
117+
}
118+
119+
function App({
120+
Component,
121+
pageProps,
122+
}: AppProps<{ values: Record<string, unknown> }>) {
123+
const { values } = pageProps;
124+
125+
return (
126+
<EffectorNext values={values}>
127+
<Component />
128+
</EffectorNext>
129+
);
130+
}
131+
132+
export default App;
133+
```
134+
92135
## Important caveats
93136

94137
There are a few special nuances of Next.js behaviour, that you need to consider.
@@ -102,25 +145,25 @@ Normally in typical SSR application you could use it to calculate some server-on
102145
```tsx
103146
// typical custom ssr example
104147
// some-module.ts
105-
export const $serverOnlyValue = createStore(null, { serialize: "ignore" })
148+
export const $serverOnlyValue = createStore(null, { serialize: "ignore" });
106149

107150
// request handler
108151

109152
export async function renderApp(req) {
110-
const scope = fork()
111-
112-
await allSettled(appStarted, { scope, params: req })
113-
114-
// serialization boundaries
115-
const appContent = renderToString(
116-
// scope object can be used for the render directly
117-
<Provider value={scope}>
118-
<App />
119-
</Provider>
120-
)
121-
const stateScript = `<script>self.__STATE__ = ${serialize(scope)}</script>` // does not contain value of `$serverOnlyValue`
122-
123-
return htmlResponse(appContent, stateScript)
153+
const scope = fork();
154+
155+
await allSettled(appStarted, { scope, params: req });
156+
157+
// serialization boundaries
158+
const appContent = renderToString(
159+
// scope object can be used for the render directly
160+
<Provider value={scope}>
161+
<App />
162+
</Provider>
163+
);
164+
const stateScript = `<script>self.__STATE__ = ${serialize(scope)}</script>`; // does not contain value of `$serverOnlyValue`
165+
166+
return htmlResponse(appContent, stateScript);
124167
}
125168
```
126169

@@ -140,7 +183,7 @@ export const $serverOnlyValue = createStore(null, { serialize: "ignore" })
140183

141184
export function Component() {
142185
const value = useUnit($serverOnlyValue)
143-
186+
144187
return value ? <>{value}<> : <>No value</>
145188
}
146189

@@ -149,7 +192,7 @@ export async function getServerSideProps(req) {
149192
const scope = fork()
150193

151194
await allSettled(appStarted, { scope, params: req })
152-
195+
153196
// scope.getState($serverOnlyValue) is not null at this point
154197

155198
return {
@@ -169,16 +212,15 @@ You can use custom serialization config instead
169212
```ts
170213
const $date = createStore<null | Date>(null, {
171214
serialize: {
172-
write: dateOrNull => (dateOrNull ? dateOrNull.toISOString() : dateOrNull),
173-
read: isoStringOrNull =>
215+
write: (dateOrNull) => (dateOrNull ? dateOrNull.toISOString() : dateOrNull),
216+
read: (isoStringOrNull) =>
174217
isoStringOrNull ? new Date(isoStringOrNull) : isoStringOrNull,
175218
},
176-
})
219+
});
177220
```
178221

179222
[Docs](https://effector.dev/docs/api/effector/createStore#example-with-custom-serialize-configuration)
180223

181-
182224
### ESM dependencies and library duplicates in the bundle
183225

184226
Since Next.js 12 [ESM imports are prioritized over CommonJS imports](https://nextjs.org/blog/next-12#es-modules-support-and-url-imports). While CJS-only dependencies are still supported, it is not recommended to use them.
@@ -191,7 +233,6 @@ You can also check it manually via `Debug -> Sources -> Webpack -> _N_E -> node_
191233

192234
<img width="418" alt="image" src="https://user-images.githubusercontent.com/32790736/233786487-304cfac0-3686-460b-b2f9-9fb0de38a4dc.png">
193235

194-
195236
## ⚠️ App directory (Next.js Beta) ⚠️
196237

197238
#### 0. Make sure you aware of current status of the App directory
@@ -216,18 +257,17 @@ To do so, create `effector-provider.tsx` file at the top level of your `app` dir
216257

217258
```tsx
218259
// app/effector-provider.tsx
219-
'use client';
260+
"use client";
220261

221-
import type { ComponentProps } from 'react';
222-
import { EffectorNext } from '@effector/next';
262+
import type { ComponentProps } from "react";
263+
import { EffectorNext } from "@effector/next";
223264

224265
export function EffectorAppNext({
225266
values,
226267
children,
227268
}: ComponentProps<typeof EffectorNext>) {
228269
return <EffectorNext values={values}>{children}</EffectorNext>;
229270
}
230-
231271
```
232272

233273
You should use this version of provider in the `app` directory from now on.
@@ -242,18 +282,16 @@ If you are using [multiple Root Layouts](https://beta.nextjs.org/docs/routing/de
242282

243283
```tsx
244284
// app/layout.tsx
245-
import { EffectorAppNext } from "project-root/app/effector-provider"
285+
import { EffectorAppNext } from "project-root/app/effector-provider";
246286

247287
export function RootLayout({ children }: { children: React.ReactNode }) {
248288
return (
249289
<html lang="en">
250290
<body>
251-
<EffectorAppNext>
252-
{/* rest of the components tree */}
253-
</EffectorAppNext>
291+
<EffectorAppNext>{/* rest of the components tree */}</EffectorAppNext>
254292
</body>
255-
</html>
256-
)
293+
</html>
294+
);
257295
}
258296
```
259297

@@ -265,7 +303,7 @@ In this case you will need to add the `EffectorAppNext` provider to the tree of
265303

266304
```tsx
267305
// app/some-path/page.tsx
268-
import { EffectorAppNext } from "project-root/app/effector-provider"
306+
import { EffectorAppNext } from "project-root/app/effector-provider";
269307

270308
export default async function Page() {
271309
const scope = fork();
@@ -278,9 +316,10 @@ export default async function Page() {
278316
<EffectorAppNext values={values}>
279317
{/* rest of the components tree */}
280318
</EffectorAppNext>
281-
)
319+
);
282320
}
283321
```
322+
284323
This will automatically render this subtree with effector's state and also will automatically "hydrate" client scope with new values.
285324

286325
You're all set. Just use effector's units anywhere in components code via `useUnit` from `effector-react`.

apps/playground-app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"dependencies": {
1313
"@effector/next": "^0.3.0",
14+
"@effector/redux-devtools-adapter": "^0.1.5",
1415
"@effector/reflect": "^8.3.1",
1516
"@faker-js/faker": "^7.6.0",
1617
"@farfetched/core": "^0.8.5",

apps/playground-app/pnpm-lock.yaml

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/playground-app/src/pages/_app.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
import type { AppProps } from "next/app";
2-
import { EffectorNext } from "@effector/next";
3-
import "mvp.css"
2+
import { EffectorNext, getClientScope } from "@effector/next";
3+
import { attachReduxDevTools } from "@effector/redux-devtools-adapter";
4+
import "mvp.css";
45

56
import { Layout } from "#root/features/layout/ui";
67

8+
const clientScope = getClientScope();
9+
10+
if (clientScope) {
11+
attachReduxDevTools({
12+
scope: clientScope,
13+
name: "playground-app",
14+
trace: true,
15+
});
16+
}
17+
718
function App({
819
Component,
920
pageProps,

src/get-scope.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
sample,
1111
} from "effector";
1212

13-
import { getClientScope } from "./get-scope";
13+
import { internalGetClientScope } from "./get-scope";
1414

1515
const up = createEvent();
1616
const longUpFx = createEffect(async () => {
@@ -45,7 +45,7 @@ describe("getClientScope", () => {
4545

4646
const serverValues = serialize(serverScope);
4747

48-
const clientScopeOne = getClientScope();
48+
const clientScopeOne = internalGetClientScope();
4949

5050
expect(clientScopeOne.getState($count)).toEqual(0);
5151
expect(clientScopeOne.getState($derived)).toEqual({ ref: 0 });
@@ -62,7 +62,7 @@ describe("getClientScope", () => {
6262

6363
expect(clientScopeOne.getState(longUpFx.inFlight)).toEqual(1);
6464

65-
const clientScopeTwo = getClientScope(serverValues);
65+
const clientScopeTwo = internalGetClientScope(serverValues);
6666

6767
expect(clientScopeTwo.getState($count)).toEqual(3);
6868
expect(clientScopeOne.getState($derived)).toEqual({ ref: 3 });
@@ -97,7 +97,7 @@ describe("getClientScope", () => {
9797

9898
const values = serialize(serverScope);
9999

100-
const clientScopeOne = getClientScope(values);
100+
const clientScopeOne = internalGetClientScope(values);
101101

102102
expect(clientScopeOne.getState($count)).toEqual(3);
103103

@@ -112,7 +112,7 @@ describe("getClientScope", () => {
112112
//
113113
// So we need to basically just ignore it, because
114114
// we already have the latest state in the client scope
115-
const clientScopeTwo = getClientScope(values);
115+
const clientScopeTwo = internalGetClientScope(values);
116116

117117
expect(clientScopeTwo.getState($count)).toEqual(4);
118118
});

src/get-scope.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
11
import { fork, Scope } from "effector";
22

33
type Values = Record<string, unknown>;
4-
export const getScope =
5-
typeof document !== "undefined" ? getClientScope : getServerScope;
4+
const isClient = typeof document !== "undefined";
5+
export const getScope = typeof isClient
6+
? internalGetClientScope
7+
: getServerScope;
68

79
function getServerScope(values?: Values) {
810
return fork({ values });
911
}
1012

13+
/**
14+
*
15+
* Handler to get current client scope.
16+
*
17+
* Required for proper integrations with dev-tools.
18+
*
19+
* @returns current client scope in browser and null in server environment
20+
*/
21+
export function getClientScope() {
22+
if (isClient) {
23+
return _currentScope;
24+
}
25+
26+
return null;
27+
}
28+
1129
/**
1230
* The following code is some VERY VERY VERY BAD HACKS.
1331
*
@@ -22,7 +40,7 @@ let prevValues: Values;
2240
*
2341
* exported for tests only
2442
*/
25-
export function getClientScope(values?: Values) {
43+
export function internalGetClientScope(values?: Values) {
2644
if (
2745
!values ||
2846
/**

0 commit comments

Comments
 (0)