128
buffer = '';
129
const fakeBody = document.createElement('body');
130
fakeBody.innerHTML = bufferedContent;
131
+ const parent =
132
+ container.nodeName === '#document' ? container.body : container;
133
while (fakeBody.firstChild) {
134
const node = fakeBody.firstChild;
135
if (
139
const script = document.createElement('script');
140
script.textContent = node.textContent;
141
fakeBody.removeChild(node);
140
- container.appendChild(script);
142
+ parent.appendChild(script);
143
} else {
142
- container.appendChild(node);
144
+ parent.appendChild(node);
145
}
146
}
147
}
148
149
+ async function actIntoEmptyDocument(callback) {
150
+ await callback();
151
+ // Await one turn around the event loop.
152
+ // This assumes that we'll flush everything we have so far.
153
+ await new Promise(resolve => {
154
+ setImmediate(resolve);
155
+ });
156
+ if (hasErrored) {
157
+ throw fatalError;
158
+ }
159
+ // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
160
+ // We also want to execute any scripts that are embedded.
161
+ // We assume that we have now received a proper fragment of HTML.
162
+ const bufferedContent = buffer;
163
+ // Test Environment
164
+ const jsdom = new JSDOM(bufferedContent, {
165
+ runScripts: 'dangerously',
166
+ });
167
+ window = jsdom.window;
168
+ document = jsdom.window.document;
169
+ container = document;
170
+ buffer = '';
171
+ }
172
+
173
function getVisibleChildren(element) {
174
const children = [];
175
let node = element.firstChild;
4220
);
4221
});
4222
4223
+ // @gate enableFloat
4224
+ it('emits html and head start tags (the preamble) before other content if rendered in the shell', async () => {
4225
+ await actIntoEmptyDocument(() => {
4226
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4227
+ <>
4228
+ <title data-baz="baz">a title</title>
4229
+ <html data-foo="foo">
4230
+ <head data-bar="bar" />
4231
+ <body>a body</body>
4232
+ </html>
4233
+ </>,
4234
+ );
4235
+ pipe(writable);
4236
+ });
4237
+ expect(getVisibleChildren(document)).toEqual(
4238
+ <html data-foo="foo">
4239
+ <head data-bar="bar">
4240
+ <title data-baz="baz">a title</title>
4241
+ </head>
4242
+ <body>a body</body>
4243
+ </html>,
4244
+ );
4245
+
4246
+ // Hydrate the same thing on the client. We expect this to still fail because <title> is not a Resource
4247
+ // and is unmatched on hydration
4248
+ const errors = [];
4249
+ ReactDOMClient.hydrateRoot(
4250
+ document,
4251
+ <>
4252
+ <title data-baz="baz">a title</title>
4253
+ <html data-foo="foo">
4254
+ <head data-bar="bar" />
4255
+ <body>a body</body>
4256
+ </html>
4257
+ </>,
4258
+ {
4259
+ onRecoverableError: (err, errInfo) => {
4260
+ errors.push(err.message);
4261
+ },
4262
+ },
4263
+ );
4264
+ expect(() => {
4265
+ try {
4266
+ expect(() => {
4267
+ expect(Scheduler).toFlushWithoutYielding();
4268
+ }).toThrow('Invalid insertion of HTML node in #document node.');
4269
+ } catch (e) {
4270
+ console.log('e', e);
4271
+ }
4272
+ }).toErrorDev(
4273
+ [
4274
+ 'Warning: Expected server HTML to contain a matching <title> in <#document>.',
4275
+ 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
4276
+ 'Warning: validateDOMNesting(...): <title> cannot appear as a child of <#document>',
4277
+ ],
4278
+ {withoutStack: 1},
4279
+ );
4280
+ expect(errors).toEqual([
4281
+ 'Hydration failed because the initial UI does not match what was rendered on the server.',
4282
+ 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
4283
+ ]);
4284
+ expect(getVisibleChildren(document)).toEqual();
4285
+ expect(() => {
4286
+ expect(Scheduler).toFlushWithoutYielding();
4287
+ }).toThrow('The node to be removed is not a child of this node.');
4288
+ });
4289
+
4290
+ // @gate enableFloat
4291
+ it('holds back body and html closing tags (the postamble) until all pending tasks are completed', async () => {
4292
+ const chunks = [];
4293
+ writable.on('data', chunk => {
4294
+ chunks.push(chunk);
4295
+ });
4296
+
4297
+ await actIntoEmptyDocument(() => {
4298
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4299
+ <html>
4300
+ <head />
4301
+ <body>
4302
+ first
4303
+ <Suspense>
4304
+ <AsyncText text="second" />
4305
+ </Suspense>
4306
+ </body>
4307
+ </html>,
4308
+ );
4309
+ pipe(writable);
4310
+ });
4311
+
4312
+ expect(getVisibleChildren(document)).toEqual(
4313
+ <html>
4314
+ <head />
4315
+ <body>{'first'}</body>
4316
+ </html>,
4317
+ );
4318
+
4319
+ await act(() => {
4320
+ resolveText('second');
4321
+ });
4322
+
4323
+ expect(getVisibleChildren(document)).toEqual(
4324
+ <html>
4325
+ <head />
4326
+ <body>
4327
+ {'first'}
4328
+ {'second'}
4329
+ </body>
4330
+ </html>,
4331
+ );
4332
+
4333
+ expect(chunks.pop()).toEqual('</body></html>');
4334
+ });
4335
+
4336
+ // @gate enableFloat
4337
+ it('recognizes stylesheet links as attributes during hydration', async () => {
4338
+ await actIntoEmptyDocument(() => {
4339
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4340
+ <>
4341
+ <link rel="stylesheet" href="foo" precedence="default" />
4342
+ <html>
4343
+ <head>
4344
+ <link rel="author" precedence="this is a nonsense prop" />
4345
+ </head>
4346
+ <body>a body</body>
4347
+ </html>
4348
+ </>,
4349
+ );
4350
+ pipe(writable);
4351
+ });
4352
+ // precedence for stylesheets is mapped to a valid data attribute that is recognized on the client
4353
+ // as opting this node into resource semantics. the use of precedence on the author link is just a
4354
+ // non standard attribute which React allows but is not given any special treatment.
4355
+ expect(getVisibleChildren(document)).toEqual(
4356
+ <html>
4357
+ <head>
4358
+ <link rel="stylesheet" href="foo" data-rprec="default" />
4359
+ <link rel="author" precedence="this is a nonsense prop" />
4360
+ </head>
4361
+ <body>a body</body>
4362
+ </html>,
4363
+ );
4364
+
4365
+ // It hydrates successfully
4366
+ const root = ReactDOMClient.hydrateRoot(
4367
+ document,
4368
+ <>
4369
+ <link rel="stylesheet" href="foo" precedence="default" />
4370
+ <html>
4371
+ <head>
4372
+ <link rel="author" precedence="this is a nonsense prop" />
4373
+ </head>
4374
+ <body>a body</body>
4375
+ </html>
4376
+ </>,
4377
+ );
4378
+ // We manually capture uncaught errors b/c Jest does not play well with errors thrown in
4379
+ // microtasks after the test completes even when it is expecting to fail (e.g. when the gate is false)
4380
+ // We need to flush the scheduler at the end even if there was an earlier throw otherwise this test will
4381
+ // fail even when failure is expected. This is primarily caused by invokeGuardedCallback replaying commit
4382
+ // phase errors which get rethrown in a microtask
4383
+ const uncaughtErrors = [];
4384
+ try {
4385
+ expect(Scheduler).toFlushWithoutYielding();
4386
+ expect(getVisibleChildren(document)).toEqual(
4387
+ <html>
4388
+ <head>
4389
+ <link rel="stylesheet" href="foo" data-rprec="default" />
4390
+ <link rel="author" precedence="this is a nonsense prop" />
4391
+ </head>
4392
+ <body>a body</body>
4393
+ </html>,
4394
+ );
4395
+ } catch (e) {
4396
+ uncaughtErrors.push(e);
4397
+ }
4398
+ try {
4399
+ expect(Scheduler).toFlushWithoutYielding();
4400
+ } catch (e) {
4401
+ uncaughtErrors.push(e);
4402
+ }
4403
+
4404
+ root.render(
4405
+ <>
4406
+ <link rel="stylesheet" href="foo" precedence="default" data-bar="bar" />
4407
+ <html>
4408
+ <head />
4409
+ <body>a body</body>
4410
+ </html>
4411
+ </>,
4412
+ );
4413
+ try {
4414
+ expect(Scheduler).toFlushWithoutYielding();
4415
+ expect(getVisibleChildren(document)).toEqual(
4416
+ <html>
4417
+ <head>
4418
+ <link
4419
+ rel="stylesheet"
4420
+ href="foo"
4421
+ data-rprec="default"
4422
+ data-bar="bar"
4423
+ />
4424
+ </head>
4425
+ <body>a body</body>
4426
+ </html>,
4427
+ );
4428
+ } catch (e) {
4429
+ uncaughtErrors.push(e);
4430
+ }
4431
+ try {
4432
+ expect(Scheduler).toFlushWithoutYielding();
4433
+ } catch (e) {
4434
+ uncaughtErrors.push(e);
4435
+ }
4436
+
4437
+ if (uncaughtErrors.length > 0) {
4438
+ throw uncaughtErrors[0];
4439
+ }
4440
+ });
4441
+
4442
+ // Temporarily this test is expected to fail everywhere. When we have resource hoisting
4443
+ // it should start to pass and we can adjust the gate accordingly
4444
+ // @gate false && enableFloat
4445
+ it('should insert missing resources during hydration', async () => {
4446
+ await actIntoEmptyDocument(() => {
4447
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4448
+ <html>
4449
+ <body>foo</body>
4450
+ </html>,
4451
+ );
4452
+ pipe(writable);
4453
+ });
4454
+
4455
+ const uncaughtErrors = [];
4456
+ ReactDOMClient.hydrateRoot(
4457
+ document,
4458
+ <>
4459
+ <link rel="stylesheet" href="foo" precedence="foo" />
4460
+ <html>
4461
+ <head />
4462
+ <body>foo</body>
4463
+ </html>
4464
+ </>,
4465
+ );
4466
+ try {
4467
+ expect(Scheduler).toFlushWithoutYielding();
4468
+ expect(getVisibleChildren(document)).toEqual(
4469
+ <html>
4470
+ <head>
4471
+ <link rel="stylesheet" href="foo" precedence="foo" />
4472
+ </head>
4473
+ <body>foo</body>
4474
+ </html>,
4475
+ );
4476
+ } catch (e) {
4477
+ uncaughtErrors.push(e);
4478
+ }
4479
+
4480
+ // need to flush again to get the invoke guarded callback error to throw in microtask
4481
+ try {
4482
+ expect(Scheduler).toFlushWithoutYielding();
4483
+ } catch (e) {
4484
+ uncaughtErrors.push(e);
4485
+ }
4486
+
4487
+ if (uncaughtErrors.length) {
4488
+ throw uncaughtErrors[0];
4489
+ }
4490
+ });
4491
+
4492
+ // @gate experimental && enableFloat
4493
+ it('fail hydration if a suitable resource cannot be found in the DOM for a given location (href)', async () => {
4494
+ gate(flags => {
4495
+ if (!(__EXPERIMENTAL__ && flags.enableFloat)) {
4496
+ throw new Error('bailing out of test');
4497
+ }
4498
+ });
4499
+ await actIntoEmptyDocument(() => {
4500
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4501
+ <html>
4502
+ <head />
4503
+ <body>a body</body>
4504
+ </html>,
4505
+ );
4506
+ pipe(writable);
4507
+ });
4508
+
4509
+ const errors = [];
4510
+ ReactDOMClient.hydrateRoot(
4511
+ document,
4512
+ <html>
4513
+ <head>
4514
+ <link rel="stylesheet" href="foo" precedence="low" />
4515
+ </head>
4516
+ <body>a body</body>
4517
+ </html>,
4518
+ {
4519
+ onRecoverableError(err, errInfo) {
4520
+ errors.push(err.message);
4521
+ },
4522
+ },
4523
+ );
4524
+ expect(() => {
4525
+ expect(Scheduler).toFlushWithoutYielding();
4526
+ }).toErrorDev(
4527
+ [
4528
+ 'Warning: A matching Hydratable Resource was not found in the DOM for <link rel="stylesheet" href="foo">',
4529
+ 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
4530
+ ],
4531
+ {withoutStack: 1},
4532
+ );
4533
+ expect(errors).toEqual([
4534
+ 'Hydration failed because the initial UI does not match what was rendered on the server.',
4535
+ 'Hydration failed because the initial UI does not match what was rendered on the server.',
4536
+ 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
4537
+ ]);
4538
+ });
4539
+
4540
+ // @gate experimental && enableFloat
4541
+ it('should error in dev when rendering more than one resource for a given location (href)', async () => {
4542
+ gate(flags => {
4543
+ if (!(__EXPERIMENTAL__ && flags.enableFloat)) {
4544
+ throw new Error('bailing out of test');
4545
+ }
4546
+ });
4547
+ await actIntoEmptyDocument(() => {
4548
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4549
+ <>
4550
+ <link rel="stylesheet" href="foo" precedence="low" />
4551
+ <link rel="stylesheet" href="foo" precedence="high" />
4552
+ <html>
4553
+ <head />
4554
+ <body>a body</body>
4555
+ </html>
4556
+ </>,
4557
+ );
4558
+ pipe(writable);
4559
+ });
4560
+ expect(getVisibleChildren(document)).toEqual(
4561
+ <html>
4562
+ <head>
4563
+ <link rel="stylesheet" href="foo" data-rprec="low" />
4564
+ <link rel="stylesheet" href="foo" data-rprec="high" />
4565
+ </head>
4566
+ <body>a body</body>
4567
+ </html>,
4568
+ );
4569
+
4570
+ const errors = [];
4571
+ ReactDOMClient.hydrateRoot(
4572
+ document,
4573
+ <>
4574
+ <html>
4575
+ <head>
4576
+ <link rel="stylesheet" href="foo" precedence="low" />
4577
+ <link rel="stylesheet" href="foo" precedence="high" />
4578
+ </head>
4579
+ <body>a body</body>
4580
+ </html>
4581
+ </>,
4582
+ {
4583
+ onRecoverableError(err, errInfo) {
4584
+ errors.push(err.message);
4585
+ },
4586
+ },
4587
+ );
4588
+ expect(() => {
4589
+ expect(Scheduler).toFlushWithoutYielding();
4590
+ }).toErrorDev([
4591
+ '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"',
4592
+ '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"',
4593
+ ]);
4594
+ expect(errors).toEqual([]);
4595
+ });
4596
+
4597
describe('text separators', () => {
4598
// To force performWork to start before resolving AsyncText but before piping we need to wait until
4599
// after scheduleWork which currently uses setImmediate to delay performWork