Skip to content

Commit

Permalink
feat(state-wire): add ssr support
Browse files Browse the repository at this point in the history
  • Loading branch information
smmoosavi committed May 17, 2024
1 parent 4ebef4c commit 29d79c7
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 6 deletions.
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
138 changes: 138 additions & 0 deletions src/ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// @vitest-environment node

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

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

beforeEach(() => {
vi.clearAllMocks();
});
describe('wire', () => {
it('should render use wire value correctly', () => {
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>');
});
it('should render 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>');
});
it('should render 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>');
});
it('should render 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', () => {
it('should render selector correctly', () => {
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>');
});
});
});
14 changes: 12 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,13 @@ export function useStateWire<V>(
const [[wire, connect], set] = useState(() => create(upLink, initialValue));
const lastUpLinkRef = useRef(upLink);

useLayoutEffect(() => {
if (!isBrowser && upLink && upLink.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 29d79c7

Please sign in to comment.