fix: hook splitting, naming, strict mode

This commit is contained in:
cha0s 2024-02-17 07:06:14 -06:00
parent 87e9836cca
commit d2fabd902d
17 changed files with 232 additions and 186 deletions

View File

@ -5,8 +5,9 @@ export const hooks = {
* Note: `req` will be only be defined when server-side rendering. * Note: `req` will be only be defined when server-side rendering.
* @param {http.ClientRequest} req The HTTP request object. * @param {http.ClientRequest} req The HTTP request object.
* @invoke SequentialAsync * @invoke SequentialAsync
* @returns {[ReactContextProvider<Props>, Props]} An array where the first element is a React * @returns {ReactContextProvider | [ReactContextProvider<Props>, Props]} A React context
* context provider and the second element is the `props` passed to the context provider. * provider or an array where the first element is a React context provider and the second
* element is the `props` passed to the provider.
*/ */
'@flecks/react.providers': (req) => { '@flecks/react.providers': (req) => {
return req ? serverSideProvider(req) : [SomeContext.Provider, {value: 'whatever'}]; return req ? serverSideProvider(req) : [SomeContext.Provider, {value: 'whatever'}];
@ -19,7 +20,9 @@ export const hooks = {
* or an array of two elements where the first element is the component and the second element * or an array of two elements where the first element is the component and the second element
* is the props passed to the component. * is the props passed to the component.
* @param {http.ClientRequest} req The HTTP request object. * @param {http.ClientRequest} req The HTTP request object.
* @invoke Async * @returns {Component | [Component<Props>, Props]} A React component or an array where the first
* element is a React component and the second element is the `props` passed to the component.
* @invoke SequentialAsync
*/ */
'@flecks/react.roots': (req) => { '@flecks/react.roots': (req) => {
// Note that we're not returning `<Component />`, but `Component`. // Note that we're not returning `<Component />`, but `Component`.

View File

@ -43,8 +43,8 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"react-router": "6.2.1", "react-router": "6.22.1",
"react-router-dom": "6.2.1", "react-router-dom": "6.22.1",
"react-tabs": "^6.0.2", "react-tabs": "^6.0.2",
"redux-first-history": "5.1.1" "redux-first-history": "5.1.1"
}, },

View File

@ -1,30 +1,21 @@
import {D} from '@flecks/core'; import {D} from '@flecks/core';
import React from 'react';
import {createRoot, hydrateRoot} from 'react-dom/client'; import {createRoot, hydrateRoot} from 'react-dom/client';
// eslint-disable-next-line import/no-extraneous-dependencies import {createRootElement} from './create-root-element';
import FlecksContext from '@flecks/react/context';
import root from './root';
const debug = D('@flecks/react/client'); const debug = D('@flecks/react/client');
export {FlecksContext};
export const hooks = { export const hooks = {
'@flecks/web/client.up': async (container, flecks) => { '@flecks/web/client.up': async (container, flecks) => {
const {ssr} = flecks.get('@flecks/react'); const {ssr} = flecks.get('@flecks/react');
debug('%sing...', ssr ? 'hydrat' : 'render'); debug('%sing...', ssr ? 'hydrat' : 'render');
const RootComponent = React.createElement( const RootElement = await createRootElement(flecks);
React.StrictMode,
{},
[React.createElement(await root(flecks), {key: 'root'})],
);
// API™. // API™.
if (ssr) { if (ssr) {
hydrateRoot(container, RootComponent); hydrateRoot(container, RootElement);
} }
else { else {
createRoot(container).render(RootComponent); createRoot(container).render(RootElement);
} }
debug('rendered'); debug('rendered');
}, },

View File

@ -0,0 +1,71 @@
import {D} from '@flecks/core';
import React from 'react';
const debug = D('@flecks/react/root');
const debugSilly = debug.extend('silly');
export async function createRootElement(flecks, req, res) {
const Providers = {};
const implementingProvider = flecks.flecksImplementing('@flecks/react.providers');
for (let i = 0; i < implementingProvider.length; ++i) {
const fleck = implementingProvider[i];
const implementation = flecks.fleckImplementation(fleck, '@flecks/react.providers');
// eslint-disable-next-line no-await-in-loop
Providers[fleck] = await implementation(req, res, flecks);
}
debugSilly('providers: %O', Providers);
const Roots = {};
const implementingRoot = flecks.flecksImplementing('@flecks/react.roots');
for (let i = 0; i < implementingRoot.length; ++i) {
const fleck = implementingRoot[i];
const implementation = flecks.fleckImplementation(fleck, '@flecks/react.roots');
// eslint-disable-next-line no-await-in-loop
Roots[fleck] = await implementation(req, res, flecks);
}
debugSilly('roots: %O', Roots);
const RootElements = Object.entries(Roots)
.filter(([, ComponentOrTuple]) => ComponentOrTuple)
.map(([fleck, ComponentOrTuple]) => {
let Root;
let props = {};
if (Array.isArray(ComponentOrTuple)) {
[Root, props] = ComponentOrTuple;
}
else {
Root = ComponentOrTuple;
}
return React.createElement(Root, {key: `@flecks/react/root(${fleck})`, ...props});
});
let [RootElement] = Object.entries(Providers)
.filter(([, ComponentOrTuple]) => ComponentOrTuple)
.reduceRight(
(children, [fleck, ComponentOrTuple]) => {
let Provider;
let props = {};
if (Array.isArray(ComponentOrTuple)) {
[Provider, props] = ComponentOrTuple;
}
else {
Provider = ComponentOrTuple;
}
return [
React.createElement(
Provider,
// eslint-disable-next-line react/no-array-index-key
{key: `@flecks/react/provider(${fleck})`, ...props},
children,
),
];
},
RootElements,
);
const {strictMode} = flecks.get('@flecks/react');
if (strictMode) {
RootElement = React.createElement(
React.StrictMode,
{},
[React.cloneElement(RootElement, {key: '@flecks/react.strictMode'})],
);
}
return RootElement;
}

View File

@ -1,15 +1,12 @@
import FlecksContext from './context';
export {default as classnames} from 'classnames'; export {default as classnames} from 'classnames';
export {default as PropTypes} from 'prop-types'; export {default as PropTypes} from 'prop-types';
export {default as React} from 'react'; export {default as React} from 'react';
export {default as ReactDom} from 'react-dom'; export {default as ReactDom} from 'react-dom';
export * from 'react'; export * from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved export * from './react-hooks';
export {default as FlecksContext} from '@flecks/react/context';
export {default as gatherComponents} from './gather-components';
export {default as useEvent} from './hooks/use-event';
export {default as useFlecks} from './hooks/use-flecks';
export {default as usePrevious} from './hooks/use-previous';
export const hooks = { export const hooks = {
'@flecks/core.config': () => ({ '@flecks/core.config': () => ({
@ -21,6 +18,11 @@ export const hooks = {
* Whether to enable server-side rendering. * Whether to enable server-side rendering.
*/ */
ssr: true, ssr: true,
/**
* Whether to wrap the React root with `React.StrictMode`.
*/
strictMode: true,
}), }),
'@flecks/react.providers': (req, res, flecks) => [FlecksContext.Provider, {value: flecks}],
'@flecks/web.config': async (req, flecks) => flecks.get('@flecks/react'), '@flecks/web.config': async (req, flecks) => flecks.get('@flecks/react'),
}; };

View File

@ -0,0 +1,3 @@
export {default as useEvent} from './use-event';
export {default as useFlecks} from './use-flecks';
export {default as usePrevious} from './use-previous';

View File

@ -1,5 +1,4 @@
// eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved import FlecksContext from '@flecks/react/context';
import {FlecksContext} from '@flecks/react/client';
import {useContext, useEffect, useState} from 'react'; import {useContext, useEffect, useState} from 'react';
export default () => { export default () => {

View File

@ -1,37 +0,0 @@
import {D} from '@flecks/core';
import React from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import FlecksContext from '@flecks/react/context';
import gatherComponents from './gather-components';
const debug = D('@flecks/react/root');
const debugSilly = debug.extend('silly');
export default async (flecks, req) => {
const Roots = await flecks.invokeAsync('@flecks/react.roots', req);
debugSilly('roots: %O', Roots);
const Providers = await flecks.invokeSequentialAsync('@flecks/react.providers', req);
const FlattenedProviders = [];
for (let i = 0; i < Providers.length; i++) {
const Provider = Providers[i];
if (Provider.length > 0) {
FlattenedProviders.push(...(Array.isArray(Provider[0]) ? Provider : [Provider]));
}
}
debugSilly('providers: %O', FlattenedProviders);
return () => {
const RootElements = [[FlecksContext.Provider, {value: flecks}]]
.concat(FlattenedProviders)
.reduceRight((children, [Provider, props], i) => [
React.createElement(
Provider,
// eslint-disable-next-line react/no-array-index-key
{key: `@flecks/react/provider(${i})`, ...props},
children,
),
], gatherComponents(Roots));
return RootElements[0];
};
};

View File

@ -1,13 +0,0 @@
import {Flecks} from '@flecks/core';
import ssr from './ssr';
export const hooks = {
'@flecks/electron/server.extensions': (installer) => [installer.REACT_DEVELOPER_TOOLS],
'@flecks/web/server.stream.html': Flecks.priority(
(stream, req, res, flecks) => (
flecks.get('@flecks/react.ssr') ? ssr(stream, req, flecks) : stream
),
{after: '@flecks/web/server'},
),
};

View File

@ -0,0 +1 @@
export const hook = (installer) => [installer.REACT_DEVELOPER_TOOLS];

View File

@ -0,0 +1,56 @@
import {WritableStream} from 'htmlparser2/lib/WritableStream';
export async function parseHtml(stream) {
const css = [];
let hasVendor = false;
let inline = '';
let isInScript = 0;
let isSkipping = false;
const js = [];
const parserStream = new WritableStream({
onclosetag(tagName) {
if ('script' === tagName) {
isInScript -= 1;
isSkipping = false;
}
},
onopentag(tagName, attribs) {
if ('script' === tagName) {
isInScript += 1;
if ('ignore' === attribs?.['data-flecks']) {
isSkipping = true;
}
else if (attribs.src) {
if (attribs.src.match(/web-vendor\.js$/)) {
hasVendor = true;
}
else {
js.push(attribs.src);
}
}
}
if ('style' === tagName && attribs['data-href']) {
css.push(attribs['data-href']);
}
if ('link' === tagName && 'stylesheet' === attribs.rel && attribs.href) {
css.push(attribs.href);
}
},
ontext(text) {
if (isInScript > 0 && !isSkipping) {
inline += text;
}
},
});
await new Promise((resolve, reject) => {
const piped = stream.pipe(parserStream);
piped.on('error', reject);
piped.on('finish', resolve);
});
return {
css,
hasVendor,
inline,
js,
};
}

View File

@ -0,0 +1,77 @@
import {Readable} from 'stream';
import React from 'react';
import {renderToPipeableStream} from 'react-dom/server';
import {Flecks} from '@flecks/core';
import {pipesink} from '@flecks/core/server';
import {configSource} from '@flecks/web/server';
import {createRootElement} from '@flecks/react/create-root-element';
import DocumentComponent from './_document';
import {parseHtml} from './_parse-html';
export const hook = Flecks.priority(
async (stream, req, res, flecks) => {
if (!flecks.get('@flecks/react.ssr')) {
return stream;
}
const {
appMountId,
base,
icon,
meta,
title,
} = flecks.get('@flecks/web');
// Fallback.
const fallback = pipesink(stream);
// Extract assets from HTML generated up to this point.
const {
css,
hasVendor,
inline,
js,
} = await parseHtml(stream);
// Render document.
const DocumentElement = React.createElement(
DocumentComponent,
{
appMountId: flecks.interpolate(appMountId),
base: flecks.interpolate(base),
config: React.createElement(
'script',
{dangerouslySetInnerHTML: {__html: await configSource(flecks, req)}},
),
css,
hasVendor,
icon,
meta,
root: await createRootElement(flecks, req, res),
title: flecks.interpolate(title),
},
);
return new Promise((resolve) => {
const rendered = renderToPipeableStream(
DocumentElement,
{
bootstrapScripts: js,
bootstrapScriptContent: inline,
async onError(error) {
// eslint-disable-next-line no-console
console.error('SSR error:', error);
resolve(Readable.from(await fallback));
},
async onShellError(error) {
// eslint-disable-next-line no-console
console.error('SSR shell error:', error);
resolve(Readable.from(await fallback));
},
onShellReady() {
resolve(rendered);
},
},
);
});
},
{after: '@flecks/web/server'},
);

View File

@ -0,0 +1,3 @@
import {Flecks} from '@flecks/core';
export const hooks = Flecks.hooks(require.context('./hooks'));

View File

@ -1,110 +0,0 @@
import {Readable} from 'stream';
import {WritableStream} from 'htmlparser2/lib/WritableStream';
import React from 'react';
import {renderToPipeableStream} from 'react-dom/server';
import {configSource} from '@flecks/web/server';
import Document from './document';
import root from './root';
export default async (stream, req, flecks) => {
const {
appMountId,
base,
icon,
meta,
title,
} = flecks.get('@flecks/web');
// Extract assets.
const css = [];
let hasVendor = false;
let inline = '';
let isInScript = 0;
let isSkipping = false;
const js = [];
const parserStream = new WritableStream({
onclosetag(tagName) {
if ('script' === tagName) {
isInScript -= 1;
isSkipping = false;
}
},
onopentag(tagName, attribs) {
if ('script' === tagName) {
isInScript += 1;
if ('ignore' === attribs?.['data-flecks']) {
isSkipping = true;
}
else if (attribs.src) {
if (attribs.src.match(/web-vendor\.js$/)) {
hasVendor = true;
}
else {
js.push(attribs.src);
}
}
}
if ('style' === tagName && attribs['data-href']) {
css.push(attribs['data-href']);
}
if ('link' === tagName && 'stylesheet' === attribs.rel && attribs.href) {
css.push(attribs.href);
}
},
ontext(text) {
if (isInScript > 0 && !isSkipping) {
inline += text;
}
},
});
const chunks = [];
stream.on('data', (chunk) => {
chunks.push(chunk);
});
await new Promise((resolve, reject) => {
const piped = stream.pipe(parserStream);
piped.on('error', reject);
piped.on('finish', resolve);
});
// Render document.
const DocumentElement = React.createElement(
Document,
{
appMountId: flecks.interpolate(appMountId),
base: flecks.interpolate(base),
config: React.createElement(
'script',
{dangerouslySetInnerHTML: {__html: await configSource(flecks, req)}},
),
css,
hasVendor,
icon,
meta,
root: React.createElement(await root(flecks, req)),
title: flecks.interpolate(title),
},
);
return new Promise((resolve) => {
const rendered = renderToPipeableStream(
DocumentElement,
{
bootstrapScripts: js,
bootstrapScriptContent: inline,
onError(error) {
// eslint-disable-next-line no-console
console.error('SSR error:', error);
resolve(Readable.from(Buffer.concat(chunks)));
},
onShellError(error) {
// eslint-disable-next-line no-console
console.error('SSR shell error:', error);
resolve(Readable.from(Buffer.concat(chunks)));
},
onShellReady() {
resolve(rendered);
},
},
);
});
};