Skip to content

Commit

Permalink
Merge pull request #212 from theKashey/suspense-hydration
Browse files Browse the repository at this point in the history
Suspense hydration
  • Loading branch information
theKashey authored Mar 5, 2021
2 parents 03165dc + f3f005d commit 76131b2
Show file tree
Hide file tree
Showing 14 changed files with 134 additions and 28 deletions.
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ module.exports = [
{
path: ['dist/es2015/entrypoints/index.js', 'dist/es2015/entrypoints/boot.js'],
ignore: ['tslib'],
limit: '3.9 KB',
limit: '4.2 KB',
},
{
path: 'dist/es2015/entrypoints/index.js',
ignore: ['tslib'],
limit: '3.6 KB',
limit: '3.8 KB',
},
{
path: 'dist/es2015/entrypoints/boot.js',
Expand Down
6 changes: 3 additions & 3 deletions .size.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
{
"name": "dist/es2015/entrypoints/index.js, dist/es2015/entrypoints/boot.js",
"passed": true,
"size": 3975
"size": 4217
},
{
"name": "dist/es2015/entrypoints/index.js",
"passed": true,
"size": 3606
"size": 3852
},
{
"name": "dist/es2015/entrypoints/boot.js",
"passed": true,
"size": 1923
"size": 1938
}
]
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ const ClientSideOnly = () => (
const ServerSideFriendly = () => (
<LazyBoundary>
{' '}
// LazyBoundary is Suspense on the client, and "nothing" on the server
// LazyBoundary is Suspense* on the client, and "nothing" on the server
<Component />
</LazyBoundary>
);
Expand Down Expand Up @@ -254,7 +254,7 @@ If you have `imported` definition in one file, and use it from another - just `i

- `importFunction` - function which resolves with Component to be imported.
- `options` - optional settings
- `options.async` - activates react suspense support. Will throw a Promise in a Loading State - use it with Suspense in a same way you use **React.lazy**.
- `options.async` - activates react suspense support. Will throw a Promise in a Loading State - use it with Suspense in a same way you use **React.lazy**. See [working with Suspense](working-with-suspense)
- `options.LoadingComponent` - component to be shown in Loading state
- `options.ErrorComponent` - component to be shown in Error state. Will re-throw error if ErrorComponent is not set. Use ErrorBoundary to catch it.
- `options.onError` - function to consume the error, if one will thrown. Will rethrow a real error if not set.
Expand Down Expand Up @@ -291,6 +291,10 @@ Hints:
- use `options.import=false` to perform conditional import - `importFunction` would not be used if this option set to `false.
- use `options.track=true` to perform SSR only import - to usage would be tracked if this option set to `false.

##### ImportedController

- `<ImportedControoler>` - a controller for Suspense Hydration. **Compulsory** for async/lazy usecases

##### Misc

There is also API method, unique for imported-component, which could be useful on the client side
Expand Down Expand Up @@ -442,12 +446,17 @@ Before rendering your application you have to ensure - all parts are loaded.
`rehydrateMarks` will load everything you need, and provide a promise to await.

```js
import { rehydrateMarks } from 'react-imported-component';
import { rehydrateMarks, ImportedController } from 'react-imported-component';

// this will trigger all marked imports, and await for competition.
rehydrateMarks().then(() => {
// better
ReactDOM.hydrate(<App />, document.getElementById('main'));
// better (note ImportedController usage)
ReactDOM.hydrate(
<ImportedController>
<App />
</ImportedController>,
document.getElementById('main')
);
// or
ReactDOM.render(<App />, document.getElementById('main'));
});
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"main": "dist/es5/entrypoints/index.js",
"jsnext:main": "dist/es2015/entrypoints/index.js",
"module": "dist/es2015/entrypoints/index.js",
"sideEffects": false,
"types": "dist/es5/entrypoints/index.d.ts",
"sideEffects": false,
"scripts": {
"build:ci": "lib-builder build && yarn size",
"build": "rm -Rf ./dist/* && lib-builder build && yarn size && yarn size:report",
Expand Down Expand Up @@ -79,7 +79,7 @@
"crc-32": "^1.2.0",
"detect-node-es": "^1.0.0",
"scan-directory": "^2.0.0",
"tslib": "^1.10.0"
"tslib": "^2.0.0"
},
"engines": {
"node": ">=8.5.0"
Expand Down
6 changes: 5 additions & 1 deletion src/entrypoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import { ImportedComponent } from '../ui/Component';
import { ImportedComponent as ComponentLoader } from '../ui/Component';
import { ImportedStream } from '../ui/context';
import imported, { lazy } from '../ui/HOC';
import LazyBoundary from '../ui/LazyBoundary';
import { ImportedController } from '../ui/ImportedController';
import { LazyBoundary } from '../ui/LazyBoundary';
import { ImportedModule, importedModule } from '../ui/Module';
import { useImported, useLazy, useLoadable } from '../ui/useImported';
import { remapImports } from '../utils/helpers';
import { useIsClientPhase } from '../utils/useClientPhase';

export {
printDrainHydrateMarks,
Expand All @@ -36,6 +38,8 @@ export {
importedModule,
lazy,
LazyBoundary,
ImportedController,
useIsClientPhase,
remapImports,
useLoadable,
useImported,
Expand Down
2 changes: 2 additions & 0 deletions src/entrypoints/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { drainHydrateMarks, printDrainHydrateMarks } from '../loadable/marks';
import { createLoadableStream } from '../loadable/stream';
import { getLoadableTrackerCallback } from '../trackers/globalTracker';
import { createLoadableTransformer } from '../transformers/loadableTransformer';
import { Stream as ImportedStreamTracker } from '../types';
import { ImportedStream } from '../ui/context';

export {
Expand All @@ -16,4 +17,5 @@ export {
getLoadableTrackerCallback,
getMarkedChunks,
getMarkedFileNames,
ImportedStreamTracker,
};
7 changes: 6 additions & 1 deletion src/loadable/assignImportedComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ export const assignImportedComponents = (set: ImportedDefinition[]) => {
assignMetaData(loadable.mark, loadable, imported[1], imported[2]);
});

if (countBefore === LOADABLE_SIGNATURE.size) {
if (set.length === 0) {
// tslint:disable-next-line:no-console
console.error('react-imported-component: no import-marks found, please check babel plugin');
}

if (countBefore === LOADABLE_SIGNATURE.size) {
// tslint:disable-next-line:no-console
console.error('react-imported-component: no new imports found');
}

done();

return set;
Expand Down
2 changes: 1 addition & 1 deletion src/loadable/stream.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Stream } from '../types';

export const createLoadableStream = () => ({ marks: {} });
export const createLoadableStream = (): Stream => ({ marks: {} });
export const clearStream = (stream?: Stream) => {
if (stream) {
stream.marks = {};
Expand Down
10 changes: 8 additions & 2 deletions src/ui/HOC.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ function loader<P, K = P>(
loaderFunction: DefaultComponentImport<P>,
baseOptions: Partial<ComponentOptions<P, K>> & HOCOptions = {}
): HOCType<P, K> {
const loadable = getLoadable(loaderFunction);
let loadable = getLoadable(loaderFunction);

const Imported = React.forwardRef<any, any>(function ImportedComponentHOC({ importedProps = {}, ...props }, ref) {
const options = { ...baseOptions, ...importedProps };
// re-get loadable in order to have fresh reference
loadable = getLoadable(loaderFunction);

return (
<ImportedComponent
Expand All @@ -49,7 +51,11 @@ function loader<P, K = P>(

return loadable.resolution;
};
Imported.done = loadable.resolution;
Object.defineProperty(Imported, 'done', {
get() {
return loadable.resolution;
},
});

return Imported;
}
Expand Down
46 changes: 46 additions & 0 deletions src/ui/ImportedController.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React, { createContext, useCallback, useLayoutEffect, useState } from 'react';

interface ImportedState {
usesHydration: boolean;
pastHydration: boolean;
}

export const importedState = createContext<ImportedState | undefined>(undefined);

export const HydrationState: React.FC<{ state: ImportedState }> = ({ state, children }) => (
<importedState.Provider value={state}>{children}</importedState.Provider>
);

/**
* this component just creates a "the first-most" effect in the system
*/
const HydrationEffect = ({ loopCallback }: { loopCallback(): void }): null => {
useLayoutEffect(loopCallback, []);
return null;
};

/**
* @see [LazyBoundary]{@link LazyBoundary} - HydrationController is required for LazyBoundary to properly work with React>16.10
* Established a control over LazyBoundary suppressing fallback during the initial hydration
* @param props
* @param [props.usesHydration=true] determines of Application is rendered using hydrate
*/
export const ImportedController: React.FC<{
/**
* determines of Application is rendered using hydrate
*/
usesHydration?: boolean;
}> = ({ children, usesHydration = true }) => {
const [state, setState] = useState<ImportedState>({
usesHydration,
pastHydration: false,
});

const onFirstHydration = useCallback(() => setState(oldState => ({ ...oldState, pastHydration: true })), []);
return (
<>
<HydrationEffect loopCallback={onFirstHydration} />
<HydrationState state={state}>{children}</HydrationState>
</>
);
};
23 changes: 18 additions & 5 deletions src/ui/LazyBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import * as React from 'react';
import { isBackend } from '../utils/detectBackend';
import { useIsClientPhase } from '../utils/useClientPhase';

const LazyBoundary: React.FC<{
const LazyServerBoundary: React.FC<{
fallback: NonNullable<React.ReactNode> | null;
}> = ({ children }) => <React.Fragment>{children}</React.Fragment>;

const LazyClientBoundary: React.FC<{
fallback: NonNullable<React.ReactNode> | null;
}> = ({ children, fallback }) => (
<React.Suspense
// we keep fallback null during hydration as it is expected behavior for "ssr-ed" Suspense blocks - they should not "fallback"
// see https://github.com/sebmarkbage/react/blob/185700696ebbe737c99bd6c4b678d5f2a923bd29/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js#L668-L682
fallback={useIsClientPhase() ? fallback : (undefined as any)}
>
{children}
</React.Suspense>
);

/**
* React.Suspense "as-is" replacement
* React.Suspense "as-is" replacement. Automatically "removed" during SSR and "patched" to work accordingly on the clientside
*
* @see {@link HydrationController} has to wrap entire application in order to provide required information
*/
const Boundary = isBackend ? LazyBoundary : React.Suspense;

export default Boundary;
export const LazyBoundary = isBackend ? LazyServerBoundary : LazyClientBoundary;
8 changes: 3 additions & 5 deletions src/ui/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@ import * as React from 'react';
import { defaultStream } from '../loadable/stream';
import { Stream } from '../types';

interface TakeProps {
stream: Stream;
}

export const streamContext = React.createContext(defaultStream);

/**
* SSR. Tracker for used marks
*/
export const ImportedStream: React.FC<TakeProps> = ({ stream, children, ...props }) => {
export const ImportedStream: React.FC<{
stream: Stream;
}> = ({ stream, children, ...props }) => {
if (process.env.NODE_ENV !== 'development') {
if ('takeUID' in props) {
throw new Error('react-imported-component: `takeUID` was replaced by `stream`.');
Expand Down
18 changes: 18 additions & 0 deletions src/utils/useClientPhase.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useContext } from 'react';
import { importedState } from '../ui/ImportedController';

/**
* returns "true" if currently is a "client" phase and all features should be active
* @see {@link HydrationController}
*/
export const useIsClientPhase = (): boolean => {
const value = useContext(importedState);
if (!value) {
if (process.env.NODE_ENV !== 'production') {
// tslint:disable-next-line:no-console
console.warn('react-imported-component: please wrap your entire application with ImportedController');
}
return true;
}
return value.pastHydration;
};
7 changes: 6 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15976,7 +15976,7 @@ ts-pnp@^1.1.6:
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92"
integrity sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==

tslib@^1.10.0, tslib@^1.6.0, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.3:
tslib@^1.6.0, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.3:
version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
Expand All @@ -15986,6 +15986,11 @@ tslib@^1.9.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"
integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==

tslib@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==

tslint-config-prettier@^1.18.0:
version "1.18.0"
resolved "https://registry.yarnpkg.com/tslint-config-prettier/-/tslint-config-prettier-1.18.0.tgz#75f140bde947d35d8f0d238e0ebf809d64592c37"
Expand Down

0 comments on commit 76131b2

Please sign in to comment.