Skip to content

Commit

Permalink
feat(state-wire): add ssr support (#79)
Browse files Browse the repository at this point in the history
  • Loading branch information
smmoosavi committed May 17, 2024
1 parent 19b4d37 commit 5ea8e64
Show file tree
Hide file tree
Showing 7 changed files with 221 additions and 8 deletions.
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,9 @@ const valueWire = useInterceptor(

During initialization, wire value always has more priority.

- If the up-link wire has value, the initial value will be ignored and respect wire value
- If the up-link wire has `undefined` value, the initial value will be used and wire value will be updated
- If the up-link wire has value, the initial value will be ignored and respect up-link wire value
- In CSR, If the up-link wire has `undefined` value, the initial value will be used and up-link wire value will be updated
- In SSR, If the up-link wire has `undefined` value, the initial value will be used and a warning will be shown

Examples:

Expand Down Expand Up @@ -515,6 +516,26 @@ wire.getValue(); // => 2
state; // => 2
```

```tsx
// ssr
const wire1 = useWire(null);
const wire2 = useWire(wire1, 2); // warning: 'upLink value is undefined. uplink without value is not supported in server side rendering'
wire1.getValue(); // => undefined
wire2.getValue(); // => 2
```

```tsx
// ssr
const wire1 = useWire(null);
const wire2 = useWire(wire1);
wire1.getValue(); // => undefined
wire2.getValue(); // => undefined
```

### Server Side Rendering

In SSR, up-link wire without value is not supported. if you use up-link wire without value, a warning will be shown.

### Rewiring

Please avoid changing the wire variable. if wire argument changed, a warning will be shown.
Expand Down
5 changes: 3 additions & 2 deletions src/fn-wire/use-fns-wire.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useIsomorphicLayoutEffect } from '../utils/use-isomorphic-layout-effect';
import { createFnsWire } from './create-fns-wire';
import { FnsWire } from './fns-wire';

Expand All @@ -11,7 +12,7 @@ export function useFnsWire<Fns extends {} = {}>(
): FnsWire<Fns> {
const [[wire, connect], set] = useState(() => create(upLink));
const lastUpLinkRef = useRef(upLink);
useLayoutEffect(() => {
useIsomorphicLayoutEffect(() => {
if (lastUpLinkRef.current !== upLink) {
lastUpLinkRef.current = upLink;
set(create(upLink));
Expand Down
5 changes: 3 additions & 2 deletions src/interceptor/use-interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { WireState } from '../state-wire/readonly-state-wire';
import { StateWire } from '../state-wire/state-wire';
import { useIsomorphicLayoutEffect } from '../utils/use-isomorphic-layout-effect';
import { createInterceptor } from './create-interceptor';
import { Interceptor } from './interceptor';

Expand Down Expand Up @@ -38,7 +39,7 @@ export function useInterceptor<W extends StateWire<any>>(
createInterceptor<WireState<W>, W>(wire, interceptor),
);
const lastWireRef = useRef(wire);
useLayoutEffect(() => {
useIsomorphicLayoutEffect(() => {
if (lastWireRef.current !== wire) {
lastWireRef.current = wire;
set(createInterceptor<WireState<W>, W>(wire, interceptor));
Expand Down
164 changes: 164 additions & 0 deletions src/ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// @vitest-environment node

import { renderToString } from 'react-dom/server';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { useSelector, useWire, useWireValue, Wire } from './index';

describe('ssr', () => {
afterEach(() => {
vi.restoreAllMocks();
});

beforeEach(() => {
vi.clearAllMocks();
});
describe('wire', () => {
test('use wire value', () => {
function MyComponent() {
let wire = useWire(null, 5);
const value = useWireValue(wire) ?? 'missing';
return <div>{value}</div>;
}

const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
let view = renderToString(<MyComponent />);
expect(mockConsoleError.mock.lastCall).toBeUndefined();

expect(view).toBe('<div>5</div>');
});
test('wire with uplink', () => {
function Parent() {
let wire = useWire(null, 5);
const value = useWireValue(wire) ?? 'missing';
return (
<div>
{value}
<Child wire={wire} />
</div>
);
}

function Child(props: { wire: Wire<number> }) {
let wire = useWire(props.wire, 10);
const value = useWireValue(wire) ?? 'missing';
return <span>{value}</span>;
}

const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
let view = renderToString(<Parent />);
expect(mockConsoleError.mock.lastCall).toBeUndefined();

expect(view).toBe('<div>5<span>5</span></div>');
});
test('wire with uninitialized uplink', () => {
function Parent() {
let wire = useWire<number>(null);
const value = useWireValue(wire) ?? 'missing';
return (
<div>
{value}
<Child wire={wire} />
</div>
);
}

function Child(props: { wire: Wire<number | undefined> }) {
let wire = useWire(props.wire, 10);
const value = useWireValue(wire) ?? 'missing';
return <span>{value}</span>;
}

const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
let view = renderToString(<Parent />);
expect(mockConsoleError.mock.lastCall).toEqual([
'upLink value is undefined. uplink without value is not supported in server side rendering',
]);

expect(view).toBe('<div>missing<span>10</span></div>');
});
test('wire with uninitialized uplink and no initial value', () => {
function Parent() {
let wire = useWire<number>(null);
const value = useWireValue(wire) ?? 'missing';
return (
<div>
{value}
<Child wire={wire} />
</div>
);
}

function Child(props: { wire: Wire<number | undefined> }) {
let wire = useWire(props.wire);
const value = useWireValue(wire) ?? 'missing';
return <span>{value}</span>;
}

const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
let view = renderToString(<Parent />);
expect(mockConsoleError.mock.lastCall).toBeUndefined();

expect(view).toBe('<div>missing<span>missing</span></div>');
});
test('wire with uninitialized uplink with sibling', () => {
function Parent() {
let wire = useWire<number>(null);
const value = useWireValue(wire) ?? 'missing';
return (
<div>
{value}
<Child wire={wire} init={1} />
<Child wire={wire} init={2} />
</div>
);
}

function Child(props: { wire: Wire<number | undefined>; init: number }) {
let wire = useWire(props.wire, props.init);
const value = useWireValue(wire) ?? 'missing';
return <span>{value}</span>;
}

const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
let view = renderToString(<Parent />);
expect(mockConsoleError.mock.lastCall).toEqual([
'upLink value is undefined. uplink without value is not supported in server side rendering',
]);

expect(view).toBe('<div>missing<span>1</span><span>2</span></div>');
});
});
describe('selector', () => {
test('selector', () => {
function MyComponent() {
let wire = useWire(null, 5);
let double = useSelector({ get: ({ get }) => get(wire) * 2 }, [wire]);
const value = useWireValue(wire) ?? 'missing';
const doubleValue = useWireValue(double) ?? 'missing';
return (
<div>
{value}x2={doubleValue}
</div>
);
}

const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
let view = renderToString(<MyComponent />);
expect(mockConsoleError.mock.lastCall).toBeUndefined();

expect(view).toBe('<div>5<!-- -->x2=<!-- -->10</div>');
});
});
});
19 changes: 17 additions & 2 deletions src/state-wire/use-state-wire.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { InitializerOrValue, isInitializer } from '../utils/is-initializer';
import { isDefined } from '../utils/type-utils';
import {
isBrowser,
useIsomorphicLayoutEffect,
} from '../utils/use-isomorphic-layout-effect';
import { createStateWire } from './create-state-wire';
import { StateWire } from './state-wire';

Expand Down Expand Up @@ -34,7 +38,18 @@ export function useStateWire<V>(
const [[wire, connect], set] = useState(() => create(upLink, initialValue));
const lastUpLinkRef = useRef(upLink);

useLayoutEffect(() => {
if (
!isBrowser &&
upLink &&
upLink.getValue() === undefined &&
wire.getValue() !== undefined
) {
console.error(
'upLink value is undefined. uplink without value is not supported in server side rendering',
);
}

useIsomorphicLayoutEffect(() => {
if (lastUpLinkRef.current !== upLink) {
lastUpLinkRef.current = upLink;
set(create(upLink, wire.getValue()));
Expand Down
5 changes: 5 additions & 0 deletions src/state-wire/use-wire-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@ export function useWireValue<W extends ReadonlyStateWire<any>>(
() => wire?.getValue(),
[wire],
);
const getServerSnapshot: () => Value | undefined = useCallback(
() => wire?.getValue(),
[wire],
);
const stateValue = useSyncExternalStore<Value | undefined>(
subscribe,
getSnapshot,
getServerSnapshot,
);
const valueToReturn = stateValue === undefined ? defaultValue : stateValue;
useDebugValue(valueToReturn);
Expand Down
6 changes: 6 additions & 0 deletions src/utils/use-isomorphic-layout-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useEffect, useLayoutEffect } from 'react';

export const isBrowser = typeof window !== 'undefined';
export const useIsomorphicLayoutEffect = isBrowser
? useLayoutEffect
: useEffect;

0 comments on commit 5ea8e64

Please sign in to comment.