Skip to content

Commit

Permalink
feat: add preloaders and refactor client-side only imports
Browse files Browse the repository at this point in the history
  • Loading branch information
theKashey committed Sep 18, 2019
1 parent 18d675d commit 5a54a5c
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 36 deletions.
1 change: 1 addition & 0 deletions .travis.yml
Expand Up @@ -4,6 +4,7 @@ node_js:
cache: yarn
script:
- yarn
- yarn build
- yarn test:ci
notifications:
email: false
60 changes: 52 additions & 8 deletions README.md
Expand Up @@ -85,7 +85,7 @@ Key features:


| Library | Suspense | SSR | Hooks | Library splitting | Non-modules | import(`./${value}`) |
| ------------- | ----- | ----- | ----- | ----- | ----- | ----- |
| :-------------: | :----- | ----- | ----- | ----- | ----- | ----- |
| React.lazy||||||
| react-loadable |||||||
| @loadable/component |||||||
Expand Down Expand Up @@ -213,6 +213,9 @@ What you could load using `useImported`? Everything - `imported` itself is using
- `loadable` - the underlying `Loadable` object
- `retry` - retry action (in case of error)

##### Misc
There is also API method, unique for imported-component, which could be useful on the client side
- `addPreloader(fn):fn` - adds a function, result of which would be _awaited_ when any component is loaded. Returns cancel method.

### Server side API
> import {*} from 'react-imported-component/server';
Expand Down Expand Up @@ -529,6 +532,33 @@ In short (streamed example is NOT short)
If you need _stream render_ example with __reduced TTFB__ -
please refer to [used-styles](https://github.com/theKashey/used-styles) documentation, or our [parcel-bundler stream server example](https://github.com/theKashey/react-imported-component/tree/master/examples/SSR/parcel-react-ssr/stream-server).

#### Critical style extraction and Preloader
With the critical style extracting you might not need the "real" and "full" styles on the page load -
only `js` is really required for hydration.
Thus - you might skip loading styles for the initial render, however still need them for the next render.
However - your bundler might think that you have loaded them, this is not true.
To handle this case you need `addPreloader` method

```js
// load initial bundle
// await dom ready
// -> HYDRATE APP <-
// add preloader

import {addPreloader} from 'react-imported-component/boot';

addPreloader(() => {
// check that styles are loaded
// or return Promise if they are not
});

// any not yet loaded ImportedComponent
// will await for it's own `import` as well as for
// promise returned from preloaders
```

See critical css example for details

<a name="bundler-integration"/>

## Bundler integration
Expand All @@ -538,14 +568,27 @@ Keep in mind - you dont "need" this. It will just make integration slightly bett
You might preload/prefetch used styles and scripts, which were defined with `webpackChunkName`

```js
// get mark somehow
// get mark somehow (drainHydrateMarks(importedStream))
import {getMarkedChunks} from 'react-imported-component/server';

const chunkNames = getMarkedChunks(marks);
```

#### Via webpack-imported
[webpack-imported](https://github.com/theKashey/webpack-imported) - provides `webpack` plugin to get data from the build, as well as to use it.
Supports prefetching and critical style _guidance_.

```js
import {WebpackImport} from "webpack-imported/react";
import importedStat from "build/imported.json";

<WebpackImport stats={importedStat} chunks={getMarkedChunks(drainHydrateMarks())} />
```

#### Via stat.json
If you do have `stat.json` - you can discover __all__ resources you have to preload
If you do have only `stat.json` - it could be converted into "importedStat" by `webpack-imported` without adding plugin.

Alternatively - you can discover __all__ resources you have to preload
using [flush-webpack-chunks](https://github.com/faceyspacey/webpack-flush-chunks)
```js
const { js, styles } = flushChunks(webpackStats, {
Expand Down Expand Up @@ -584,14 +627,15 @@ Use `parcel-manifest` and `getMarkedFileNames`(instead of `getMarkedChunks`) to


## React-snap
`react-imported-component` is the only (even) theoretically compatible loader for [react-snap](https://github.com/stereobooster/react-snap).

`react-imported-component` is compatible with [react-snap](https://github.com/stereobooster/react-snap) out of the box.
Works even better with `react-prerendered-component`.

## Webpack-external-import
`react-imported-component` is the only (even) theoretically compatible loader for [webpack-external-import](https://github.com/ScriptedAlchemy/webpack-external-import).
## Webpack-external-import (or native import)
`react-imported-component` is compatible with [webpack-external-import](https://github.com/ScriptedAlchemy/webpack-external-import),
as long as it executes `imports`, thus executes custom logic

## `useImported` and `Suspense`
`useImported` is not supposed to be used with Suspense, while it could
`useImported` is not supposed to be used with Suspense (by design), while it could
```js
const MyComponent = () => {
const {loading, error, loadable, imported} = useImported(() => import("..."));
Expand Down
4 changes: 4 additions & 0 deletions __tests__/loadable.spec.ts
Expand Up @@ -59,5 +59,9 @@ describe('importMatch', () => {
expect(importMatch(getFunctionSignature(`function _() {
"imported_1pn9k36_component", blablabla- importedWrapper("imported_-1556gns_component")
}`))).toEqual(['1pn9k36', '-1556gns']);
});

it('maps function signatures', () => {
expect(getFunctionSignature(`import('file')`)).toEqual(getFunctionSignature(`import(/* */'file')`))
})
});
30 changes: 22 additions & 8 deletions __tests__/utils.spec.ts
Expand Up @@ -14,10 +14,24 @@ describe('scanForImports', () => {
imports
);
expect(Object.values(imports)).toEqual([
`[() => import('${rel}/a.js'), '', '${rel}/a.js']`
`[() => import('${rel}/a.js'), '', '${rel}/a.js', false]`
]);
});

it('should map client-side import', () => {
const imports = {};
remapImports(
[{file: 'a', content: 'blabla;import(/* client-side */"./a.js"); blabla;'}],
root, root,
(a, b) => a + b,
imports
);
expect(Object.values(imports)).toEqual([
`[() => import(/* client-side */'${rel}/a.js'), '', '${rel}/a.js', true]`
]);
});


it('should map simple import with a comment', () => {
const imports = {};
remapImports(
Expand All @@ -27,7 +41,7 @@ describe('scanForImports', () => {
imports
);
expect(Object.values(imports)).toEqual([
`[() => import(/* comment:42 */'${rel}/a.js'), '', '${rel}/a.js']`
`[() => import(/* comment:42 */'${rel}/a.js'), '', '${rel}/a.js', false]`
]);
});

Expand All @@ -43,8 +57,8 @@ describe('scanForImports', () => {
imports
);
expect(Object.values(imports)).toEqual([
`[() => import(/* webpack: \"123\" */'${rel}/a.js'), '', '${rel}/a.js']`,
`[() => import(/* webpack: 123 */'${rel}/b.js'), '', '${rel}/b.js']`,
`[() => import(/* webpack: \"123\" */'${rel}/a.js'), '', '${rel}/a.js', false]`,
`[() => import(/* webpack: 123 */'${rel}/b.js'), '', '${rel}/b.js', false]`,
]);
});

Expand All @@ -60,8 +74,8 @@ describe('scanForImports', () => {
imports
);
expect(Object.values(imports)).toEqual([
`[() => import(/* webpackChunkName: "chunk-a" */'${rel}/a.js'), 'chunk-a', '${rel}/a.js']`,
`[() => import(/* webpack: 123 */'${rel}/b.js'), '', '${rel}/b.js']`,
`[() => import(/* webpackChunkName: "chunk-a" */'${rel}/a.js'), 'chunk-a', '${rel}/a.js', false]`,
`[() => import(/* webpack: 123 */'${rel}/b.js'), '', '${rel}/b.js', false]`,
]);
});

Expand All @@ -77,8 +91,8 @@ describe('scanForImports', () => {
imports
);
expect(Object.values(imports)).toEqual([
`[() => import(/* *//* webpack: \"123\" */'${rel}/a.js'), '', '${rel}/a.js']`,
`[() => import(/* */'${rel}/b.js'), '', '${rel}/b.js']`,
`[() => import(/* *//* webpack: \"123\" */'${rel}/a.js'), '', '${rel}/a.js', false]`,
`[() => import(/* */'${rel}/b.js'), '', '${rel}/b.js', false]`,
]);
});
});
4 changes: 3 additions & 1 deletion src/boot.ts
Expand Up @@ -3,7 +3,7 @@ import {done as whenComponentsReady, assignImportedComponents} from './loadable'
import {setConfiguration} from './config';
import {injectLoadableTracker} from './trackers/globalTracker';
import {loadByChunkname} from './loadByChunkName';

import {addPreloader} from "./preloaders";

export {
rehydrateMarks,
Expand All @@ -15,4 +15,6 @@ export {
setConfiguration,

injectLoadableTracker,

addPreloader,
}
24 changes: 13 additions & 11 deletions src/loadable.ts
Expand Up @@ -2,6 +2,7 @@ import {assingLoadableMark} from './marks';
import {isBackend} from './detectBackend';
import {DefaultImport, Loadable, Mark, MarkMeta, Promised} from './types';
import {settings} from "./config";
import {getPreloaders} from "./preloaders";

type AnyFunction = (x: any) => any;

Expand All @@ -14,7 +15,6 @@ let pending: Array<Promise<any>> = [];

const LOADABLE_WEAK_SIGNATURE = new WeakMap<any, Loadable<any>>();
const LOADABLE_SIGNATURE = new Map<string, Loadable<any>>();
const REJECTED_MARKS = new Set<string>();

const addPending = (promise: Promise<any>) => pending.push(promise);
const removeFromPending = (promise: Promise<any>) => pending = pending.filter(a => a !== promise);
Expand All @@ -29,14 +29,18 @@ export const importMatch = (functionString: string): Mark => {
export const getFunctionSignature = (fn: AnyFunction | string) => (
String(fn)
.replace(/(["'])/g, '`')
.replace(/\/\*([^\*]*)\*\//ig, 'DEL')
.replace(/\/\*([^\*]*)\*\//ig, '')
);

const isMarkRejected = (mark: string) => REJECTED_MARKS.has(mark);

function toLoadable<T>(firstImportFunction: Promised<T>, autoImport = true): Loadable<T> {
let importFunction = firstImportFunction;
const _load = () => Promise.resolve().then(importFunction);
const _load = (): Promise<T> => (
Promise.all([
importFunction(),
...getPreloaders(),
]).then(([result]) => result)
);
const functionSignature = getFunctionSignature(importFunction);
const mark = importMatch(functionSignature);

Expand Down Expand Up @@ -138,7 +142,7 @@ function toLoadable<T>(firstImportFunction: Promised<T>, autoImport = true): Loa
}
};

if (mark && mark.length && !mark.some(isMarkRejected)) {
if (mark && mark.length) {
LOADABLE_SIGNATURE.set(functionSignature, loadable);
assingLoadableMark(mark, loadable);
} else {
Expand Down Expand Up @@ -184,22 +188,20 @@ const assignMetaData = (mark: Mark, loadable: Loadable<any>, chunkName: string,
markMeta.push({mark, loadable, chunkName, fileName});
};

type ImportedDefinition = [Promised<any>, string, string]
type ImportedDefinition = [Promised<any>, string, string, boolean];

export const assignImportedComponents = (set: Array<ImportedDefinition>) => {
const countBefore = LOADABLE_SIGNATURE.size;
set.forEach(imported => {
if (!settings.fileFilter(imported[2])) {
const marks = importMatch(getFunctionSignature(imported[0])) || [];
marks.forEach(mark => REJECTED_MARKS.add(mark))
}
const loadable = toLoadable(imported[0]);
const allowAutoLoad = !(imported[3] || !settings.fileFilter(imported[2]));
const loadable = toLoadable(imported[0], allowAutoLoad);
assignMetaData(loadable.mark, loadable, imported[1], imported[2]);
});

if (countBefore === LOADABLE_SIGNATURE.size) {
console.error('react-imported-component: no import-marks found, please check babel plugin')
}

done();
};

Expand Down
14 changes: 14 additions & 0 deletions src/preloaders.ts
@@ -0,0 +1,14 @@
export type Preloader = () => any;
let preloaders: Preloader[] = [];

export const addPreloader = (preloader: Preloader) => {
preloaders.push(preloader);

return () => {
preloaders = preloaders.filter(p => p !== preloader);
}
};

export const getPreloaders = () => (
preloaders.map(preloader => preloader())
);
10 changes: 4 additions & 6 deletions src/scanners/scanForImports.ts
Expand Up @@ -16,7 +16,7 @@ const getComment = getMatchString(/\/\*.*\*\// as any, 0);

const getChunkName = getMatchString('webpackChunkName: "([^"]*)"', 1);

const clientSideOnly = (comment:string) => comment.indexOf('client-side') >= 0;
const clientSideOnly = (comment: string) => comment.indexOf('client-side') >= 0;

const clearComment = (str: string) => (
str
Expand Down Expand Up @@ -80,12 +80,10 @@ export const remapImports = (
.forEach(importBlock => (
importBlock
.forEach(({name, comment, doNotTransform}) => {
if (clientSideOnly(comment)) {
return;
}
const rootName = doNotTransform ? name : getRelative(root, name);
const fileName = doNotTransform ? name : getRelative(targetDir, name);
const def = `[() => import(${comment}'${fileName}'), '${getChunkName(comment)}', '${rootName}']`;
const isClientSideOnly = clientSideOnly(comment);
const def = `[() => import(${comment}'${fileName}'), '${getChunkName(comment)}', '${rootName}', ${isClientSideOnly}]`;
const slot = getRelative(root, name);

// keep the maximal definition
Expand Down Expand Up @@ -128,7 +126,7 @@ function scanTop(root: string, start: string, target: string) {
// generated by react-imported-component, DO NOT EDIT
import {assignImportedComponents} from 'react-imported-component/boot';
import {assignImportedComponents, isBackend} from 'react-imported-component/boot';
// all your imports are defined here
// all, even the ones you tried to hide in comments (that's the cost of making a very fast parser)
Expand Down

0 comments on commit 5a54a5c

Please sign in to comment.