Skip to content

Commit

Permalink
[Fizz/Float] Float for stylesheet resources
Browse files Browse the repository at this point in the history
This commit implements Float in Fizz and on the Client. The initial set of supported APIs is roughly

1. Convert certain stylesheets into style Resources when opting in with precedence prop
2. Emit preloads for stylesheets and explicit preload tags
3. Dedupe all Resources by href
4. Implement ReactDOM.preload() to allow for imperative preloading
5. Implement ReactDOM.preinit() to allow for imperative preinitialization

Currently supports
1. style Resources (link rel "stylesheet")
2. font Resources (preload as "font")

later updates will include support for scripts and modules
  • Loading branch information
gnoff committed Sep 26, 2022
1 parent cb5084d commit dea9f8c
Show file tree
Hide file tree
Showing 51 changed files with 7,453 additions and 884 deletions.
1 change: 1 addition & 0 deletions packages/react-art/src/ReactARTHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration';
export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes';
export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors';
export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMicrotasks';
export * from 'react-reconciler/src/ReactFiberHostConfigWithNoResources';

export function appendInitialChild(parentInstance, child) {
if (typeof child === 'string') {
Expand Down
5 changes: 5 additions & 0 deletions packages/react-dom/index.experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ export {
unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
version,
} from './src/client/ReactDOM';

export {preinit, preload} from './src/shared/ReactDOMFloat';

import {setDefaultDispatcher} from './src/client/ReactDOMFloatClient';
setDefaultDispatcher();
2 changes: 2 additions & 0 deletions packages/react-dom/src/ReactDOMSharedInternals.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getNodeFromInstance,
getFiberCurrentPropsFromNode,
} from './client/ReactDOMComponentTree';
import {Dispatcher} from './shared/ReactDOMDispatcher';

const Internals = {
usingClientEntryPoint: false,
Expand All @@ -30,6 +31,7 @@ const Internals = {
restoreStateIfNeeded,
batchedUpdates,
],
Dispatcher,
};

export default Internals;
262 changes: 1 addition & 261 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ describe('ReactDOMFizzServer', () => {
);
pipe(writable);
});

expect(getVisibleChildren(container)).toEqual(
<div>
<div>Loading...</div>
Expand Down Expand Up @@ -4431,267 +4432,6 @@ describe('ReactDOMFizzServer', () => {
expect(chunks.pop()).toEqual('</body></html>');
});

// @gate enableFloat
it('recognizes stylesheet links as attributes during hydration', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<>
<link rel="stylesheet" href="foo" precedence="default" />
<html>
<head>
<link rel="author" precedence="this is a nonsense prop" />
</head>
<body>a body</body>
</html>
</>,
);
pipe(writable);
});
// precedence for stylesheets is mapped to a valid data attribute that is recognized on the client
// as opting this node into resource semantics. the use of precedence on the author link is just a
// non standard attribute which React allows but is not given any special treatment.
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-rprec="default" />
<link rel="author" precedence="this is a nonsense prop" />
</head>
<body>a body</body>
</html>,
);

// It hydrates successfully
const root = ReactDOMClient.hydrateRoot(
document,
<>
<link rel="stylesheet" href="foo" precedence="default" />
<html>
<head>
<link rel="author" precedence="this is a nonsense prop" />
</head>
<body>a body</body>
</html>
</>,
);
// We manually capture uncaught errors b/c Jest does not play well with errors thrown in
// microtasks after the test completes even when it is expecting to fail (e.g. when the gate is false)
// We need to flush the scheduler at the end even if there was an earlier throw otherwise this test will
// fail even when failure is expected. This is primarily caused by invokeGuardedCallback replaying commit
// phase errors which get rethrown in a microtask
const uncaughtErrors = [];
try {
expect(Scheduler).toFlushWithoutYielding();
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-rprec="default" />
<link rel="author" precedence="this is a nonsense prop" />
</head>
<body>a body</body>
</html>,
);
} catch (e) {
uncaughtErrors.push(e);
}
try {
expect(Scheduler).toFlushWithoutYielding();
} catch (e) {
uncaughtErrors.push(e);
}

root.render(
<>
<link rel="stylesheet" href="foo" precedence="default" data-bar="bar" />
<html>
<head />
<body>a body</body>
</html>
</>,
);
try {
expect(Scheduler).toFlushWithoutYielding();
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link
rel="stylesheet"
href="foo"
data-rprec="default"
data-bar="bar"
/>
</head>
<body>a body</body>
</html>,
);
} catch (e) {
uncaughtErrors.push(e);
}
try {
expect(Scheduler).toFlushWithoutYielding();
} catch (e) {
uncaughtErrors.push(e);
}

if (uncaughtErrors.length > 0) {
throw uncaughtErrors[0];
}
});

// Temporarily this test is expected to fail everywhere. When we have resource hoisting
// it should start to pass and we can adjust the gate accordingly
// @gate false && enableFloat
it('should insert missing resources during hydration', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<body>foo</body>
</html>,
);
pipe(writable);
});

const uncaughtErrors = [];
ReactDOMClient.hydrateRoot(
document,
<>
<link rel="stylesheet" href="foo" precedence="foo" />
<html>
<head />
<body>foo</body>
</html>
</>,
);
try {
expect(Scheduler).toFlushWithoutYielding();
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" precedence="foo" />
</head>
<body>foo</body>
</html>,
);
} catch (e) {
uncaughtErrors.push(e);
}

// need to flush again to get the invoke guarded callback error to throw in microtask
try {
expect(Scheduler).toFlushWithoutYielding();
} catch (e) {
uncaughtErrors.push(e);
}

if (uncaughtErrors.length) {
throw uncaughtErrors[0];
}
});

// @gate experimental && enableFloat
it('fail hydration if a suitable resource cannot be found in the DOM for a given location (href)', async () => {
gate(flags => {
if (!(__EXPERIMENTAL__ && flags.enableFloat)) {
throw new Error('bailing out of test');
}
});
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<head />
<body>a body</body>
</html>,
);
pipe(writable);
});

const errors = [];
ReactDOMClient.hydrateRoot(
document,
<html>
<head>
<link rel="stylesheet" href="foo" precedence="low" />
</head>
<body>a body</body>
</html>,
{
onRecoverableError(err, errInfo) {
errors.push(err.message);
},
},
);
expect(() => {
expect(Scheduler).toFlushWithoutYielding();
}).toErrorDev(
[
'Warning: A matching Hydratable Resource was not found in the DOM for <link rel="stylesheet" href="foo">',
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
],
{withoutStack: 1},
);
expect(errors).toEqual([
'Hydration failed because the initial UI does not match what was rendered on the server.',
'Hydration failed because the initial UI does not match what was rendered on the server.',
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
]);
});

// @gate experimental && enableFloat
it('should error in dev when rendering more than one resource for a given location (href)', async () => {
gate(flags => {
if (!(__EXPERIMENTAL__ && flags.enableFloat)) {
throw new Error('bailing out of test');
}
});
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<>
<link rel="stylesheet" href="foo" precedence="low" />
<link rel="stylesheet" href="foo" precedence="high" />
<html>
<head />
<body>a body</body>
</html>
</>,
);
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-rprec="low" />
<link rel="stylesheet" href="foo" data-rprec="high" />
</head>
<body>a body</body>
</html>,
);

const errors = [];
ReactDOMClient.hydrateRoot(
document,
<>
<html>
<head>
<link rel="stylesheet" href="foo" precedence="low" />
<link rel="stylesheet" href="foo" precedence="high" />
</head>
<body>a body</body>
</html>
</>,
{
onRecoverableError(err, errInfo) {
errors.push(err.message);
},
},
);
expect(() => {
expect(Scheduler).toFlushWithoutYielding();
}).toErrorDev([
'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"',
'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"',
]);
expect(errors).toEqual([]);
});

describe('text separators', () => {
// To force performWork to start before resolving AsyncText but before piping we need to wait until
// after scheduleWork which currently uses setImmediate to delay performWork
Expand Down
Loading

0 comments on commit dea9f8c

Please sign in to comment.