Skip to content

Commit

Permalink
Merge pull request #11 from effector/expose-client-scope
Browse files Browse the repository at this point in the history
Expose getClientScope for dev-tools usage
  • Loading branch information
AlexandrHoroshih authored May 6, 2023
2 parents 0e2deea + cca86f9 commit 07608df
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 50 deletions.
117 changes: 78 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **⚠️ THIS PROJECT IS IN EARLY DEVELOPMENT AND IS NOT STABLE YET ⚠️**
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.
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.

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).
This package aims only at technical nuances.
Expand Down Expand Up @@ -45,7 +45,7 @@ Sid's are added automatically via either built-in babel plugin or our experiment
Add provider to the `pages/_app.tsx` and provide it with server-side `values`

```tsx
import { EffectorNext } from "@effector/next"
import { EffectorNext } from "@effector/next";

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

```ts
import { fork, allSettled, serialize } from "effector"
import { fork, allSettled, serialize } from "effector";

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

export async function getStaticProps() {
const scope = fork();
Expand All @@ -82,13 +82,56 @@ export async function getStaticProps() {
values: serialize(scope),
},
};
};
}
```

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

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

### Dev-Tools integration

Most of `effector` dev-tools options require direct access to the `scope` of the app.
At the client you can get current scope via `getClientScope` function, which will return `Scope` in the browser and `null` at the server.

Example of `@effector/redux-devtools-adapter` integration

```tsx
import type { AppProps } from "next/app";
import { EffectorNext, getClientScope } from "@effector/next";
import { attachReduxDevTools } from "@effector/redux-devtools-adapter";

const clientScope = getClientScope();

if (clientScope) {
/**
* Notice, that we need to check for the client scope first
*
* It will be `null` at the server
*/
attachReduxDevTools({
scope: clientScope,
name: "playground-app",
trace: true,
});
}

function App({
Component,
pageProps,
}: AppProps<{ values: Record<string, unknown> }>) {
const { values } = pageProps;

return (
<EffectorNext values={values}>
<Component />
</EffectorNext>
);
}

export default App;
```

## Important caveats

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

// request handler

export async function renderApp(req) {
const scope = fork()
await allSettled(appStarted, { scope, params: req })
// serialization boundaries
const appContent = renderToString(
// scope object can be used for the render directly
<Provider value={scope}>
<App />
</Provider>
)
const stateScript = `<script>self.__STATE__ = ${serialize(scope)}</script>` // does not contain value of `$serverOnlyValue`
return htmlResponse(appContent, stateScript)
const scope = fork();

await allSettled(appStarted, { scope, params: req });

// serialization boundaries
const appContent = renderToString(
// scope object can be used for the render directly
<Provider value={scope}>
<App />
</Provider>
);
const stateScript = `<script>self.__STATE__ = ${serialize(scope)}</script>`; // does not contain value of `$serverOnlyValue`

return htmlResponse(appContent, stateScript);
}
```

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

export function Component() {
const value = useUnit($serverOnlyValue)

return value ? <>{value}<> : <>No value</>
}

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

await allSettled(appStarted, { scope, params: req })

// scope.getState($serverOnlyValue) is not null at this point

return {
Expand All @@ -169,16 +212,15 @@ You can use custom serialization config instead
```ts
const $date = createStore<null | Date>(null, {
serialize: {
write: dateOrNull => (dateOrNull ? dateOrNull.toISOString() : dateOrNull),
read: isoStringOrNull =>
write: (dateOrNull) => (dateOrNull ? dateOrNull.toISOString() : dateOrNull),
read: (isoStringOrNull) =>
isoStringOrNull ? new Date(isoStringOrNull) : isoStringOrNull,
},
})
});
```

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


### ESM dependencies and library duplicates in the bundle

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.
Expand All @@ -191,7 +233,6 @@ You can also check it manually via `Debug -> Sources -> Webpack -> _N_E -> node_

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


## ⚠️ App directory (Next.js Beta) ⚠️

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

```tsx
// app/effector-provider.tsx
'use client';
"use client";

import type { ComponentProps } from 'react';
import { EffectorNext } from '@effector/next';
import type { ComponentProps } from "react";
import { EffectorNext } from "@effector/next";

export function EffectorAppNext({
values,
children,
}: ComponentProps<typeof EffectorNext>) {
return <EffectorNext values={values}>{children}</EffectorNext>;
}

```

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

```tsx
// app/layout.tsx
import { EffectorAppNext } from "project-root/app/effector-provider"
import { EffectorAppNext } from "project-root/app/effector-provider";

export function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<EffectorAppNext>
{/* rest of the components tree */}
</EffectorAppNext>
<EffectorAppNext>{/* rest of the components tree */}</EffectorAppNext>
</body>
</html>
)
</html>
);
}
```

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

```tsx
// app/some-path/page.tsx
import { EffectorAppNext } from "project-root/app/effector-provider"
import { EffectorAppNext } from "project-root/app/effector-provider";

export default async function Page() {
const scope = fork();
Expand All @@ -278,9 +316,10 @@ export default async function Page() {
<EffectorAppNext values={values}>
{/* rest of the components tree */}
</EffectorAppNext>
)
);
}
```

This will automatically render this subtree with effector's state and also will automatically "hydrate" client scope with new values.

You're all set. Just use effector's units anywhere in components code via `useUnit` from `effector-react`.
Expand Down
1 change: 1 addition & 0 deletions apps/playground-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
},
"dependencies": {
"@effector/next": "^0.3.0",
"@effector/redux-devtools-adapter": "^0.1.5",
"@effector/reflect": "^8.3.1",
"@faker-js/faker": "^7.6.0",
"@farfetched/core": "^0.8.5",
Expand Down
10 changes: 10 additions & 0 deletions apps/playground-app/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 13 additions & 2 deletions apps/playground-app/src/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import type { AppProps } from "next/app";
import { EffectorNext } from "@effector/next";
import "mvp.css"
import { EffectorNext, getClientScope } from "@effector/next";
import { attachReduxDevTools } from "@effector/redux-devtools-adapter";
import "mvp.css";

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

const clientScope = getClientScope();

if (clientScope) {
attachReduxDevTools({
scope: clientScope,
name: "playground-app",
trace: true,
});
}

function App({
Component,
pageProps,
Expand Down
10 changes: 5 additions & 5 deletions src/get-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
sample,
} from "effector";

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

const up = createEvent();
const longUpFx = createEffect(async () => {
Expand Down Expand Up @@ -45,7 +45,7 @@ describe("getClientScope", () => {

const serverValues = serialize(serverScope);

const clientScopeOne = getClientScope();
const clientScopeOne = internalGetClientScope();

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

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

const clientScopeTwo = getClientScope(serverValues);
const clientScopeTwo = internalGetClientScope(serverValues);

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

const values = serialize(serverScope);

const clientScopeOne = getClientScope(values);
const clientScopeOne = internalGetClientScope(values);

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

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

expect(clientScopeTwo.getState($count)).toEqual(4);
});
Expand Down
24 changes: 21 additions & 3 deletions src/get-scope.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
import { fork, Scope } from "effector";

type Values = Record<string, unknown>;
export const getScope =
typeof document !== "undefined" ? getClientScope : getServerScope;
const isClient = typeof document !== "undefined";
export const getScope = typeof isClient
? internalGetClientScope
: getServerScope;

function getServerScope(values?: Values) {
return fork({ values });
}

/**
*
* Handler to get current client scope.
*
* Required for proper integrations with dev-tools.
*
* @returns current client scope in browser and null in server environment
*/
export function getClientScope() {
if (isClient) {
return _currentScope;
}

return null;
}

/**
* The following code is some VERY VERY VERY BAD HACKS.
*
Expand All @@ -22,7 +40,7 @@ let prevValues: Values;
*
* exported for tests only
*/
export function getClientScope(values?: Values) {
export function internalGetClientScope(values?: Values) {
if (
!values ||
/**
Expand Down
Loading

0 comments on commit 07608df

Please sign in to comment.