main
js 1,019 lines 23.3 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
9 */
10 let JSDOM;
11 let React;
12 let ReactDOMClient;
13 let clientAct;
14 let ReactDOMFizzServer;
15 let Stream;
16 let Suspense;
17 let useId;
18 let useState;
19 let document;
20 let writable;
21 let container;
22 let buffer = '';
23 let hasErrored = false;
24 let fatalError = undefined;
25 let waitForPaint;
26 let SuspenseList;
27
28 describe('useId', () => {
29 beforeEach(() => {
30 jest.resetModules();
31 JSDOM = require('jsdom').JSDOM;
32 React = require('react');
33 ReactDOMClient = require('react-dom/client');
34 clientAct = require('internal-test-utils').act;
35 ReactDOMFizzServer = require('react-dom/server');
36 Stream = require('stream');
37 Suspense = React.Suspense;
38 useId = React.useId;
39 useState = React.useState;
40 if (gate(flags => flags.enableSuspenseList)) {
41 SuspenseList = React.unstable_SuspenseList;
42 }
43
44 const InternalTestUtils = require('internal-test-utils');
45 waitForPaint = InternalTestUtils.waitForPaint;
46
47 // Test Environment
48 const jsdom = new JSDOM(
49 '<!DOCTYPE html><html><head></head><body><div id="container">',
50 {
51 runScripts: 'dangerously',
52 },
53 );
54 document = jsdom.window.document;
55 container = document.getElementById('container');
56
57 buffer = '';
58 hasErrored = false;
59
60 writable = new Stream.PassThrough();
61 writable.setEncoding('utf8');
62 writable.on('data', chunk => {
63 buffer += chunk;
64 });
65 writable.on('error', error => {
66 hasErrored = true;
67 fatalError = error;
68 });
69 });
70
71 async function serverAct(callback) {
72 await callback();
73 // Await one turn around the event loop.
74 // This assumes that we'll flush everything we have so far.
75 await new Promise(resolve => {
76 setImmediate(resolve);
77 });
78 if (hasErrored) {
79 throw fatalError;
80 }
81 // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
82 // We also want to execute any scripts that are embedded.
83 // We assume that we have now received a proper fragment of HTML.
84 const bufferedContent = buffer;
85 buffer = '';
86 const fakeBody = document.createElement('body');
87 fakeBody.innerHTML = bufferedContent;
88 while (fakeBody.firstChild) {
89 const node = fakeBody.firstChild;
90 if (node.nodeName === 'SCRIPT') {
91 const script = document.createElement('script');
92 script.textContent = node.textContent;
93 fakeBody.removeChild(node);
94 container.appendChild(script);
95 } else {
96 container.appendChild(node);
97 }
98 }
99 }
100
101 function normalizeTreeIdForTesting(id) {
102 const result = id.match(/_(R|r)_([a-z0-9]*)(H([0-9]*))?_/);
103 if (result === undefined) {
104 throw new Error('Invalid id format');
105 }
106 const [, serverClientPrefix, base32, hookIndex] = result;
107 if (serverClientPrefix.endsWith('r')) {
108 // Client ids aren't stable. For testing purposes, strip out the counter.
109 return (
110 'CLIENT_GENERATED_ID' +
111 (hookIndex !== undefined ? ` (${hookIndex})` : '')
112 );
113 }
114 // Formats the tree id as a binary sequence, so it's easier to visualize
115 // the structure.
116 return (
117 parseInt(base32, 32).toString(2) +
118 (hookIndex !== undefined ? ` (${hookIndex})` : '')
119 );
120 }
121
122 function DivWithId({children}) {
123 const id = normalizeTreeIdForTesting(useId());
124 return <div id={id}>{children}</div>;
125 }
126
127 it('basic example', async () => {
128 function App() {
129 return (
130 <div>
131 <div>
132 <DivWithId />
133 <DivWithId />
134 </div>
135 <DivWithId />
136 </div>
137 );
138 }
139
140 await serverAct(async () => {
141 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
142 pipe(writable);
143 });
144 await clientAct(async () => {
145 ReactDOMClient.hydrateRoot(container, <App />);
146 });
147 expect(container).toMatchInlineSnapshot(`
148 <div
149 id="container"
150 >
151 <div>
152 <div>
153 <div
154 id="101"
155 />
156 <div
157 id="1001"
158 />
159 </div>
160 <div
161 id="10"
162 />
163 </div>
164 </div>
165 `);
166 });
167
168 it('indirections', async () => {
169 function App() {
170 // There are no forks in this tree, but the parent and the child should
171 // have different ids.
172 return (
173 <DivWithId>
174 <div>
175 <div>
176 <div>
177 <DivWithId />
178 </div>
179 </div>
180 </div>
181 </DivWithId>
182 );
183 }
184
185 await serverAct(async () => {
186 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
187 pipe(writable);
188 });
189 await clientAct(async () => {
190 ReactDOMClient.hydrateRoot(container, <App />);
191 });
192 expect(container).toMatchInlineSnapshot(`
193 <div
194 id="container"
195 >
196 <div
197 id="0"
198 >
199 <div>
200 <div>
201 <div>
202 <div
203 id="1"
204 />
205 </div>
206 </div>
207 </div>
208 </div>
209 </div>
210 `);
211 });
212
213 it('StrictMode double rendering', async () => {
214 const {StrictMode} = React;
215
216 function App() {
217 return (
218 <StrictMode>
219 <DivWithId />
220 </StrictMode>
221 );
222 }
223
224 await serverAct(async () => {
225 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
226 pipe(writable);
227 });
228 await clientAct(async () => {
229 ReactDOMClient.hydrateRoot(container, <App />);
230 });
231 expect(container).toMatchInlineSnapshot(`
232 <div
233 id="container"
234 >
235 <div
236 id="0"
237 />
238 </div>
239 `);
240 });
241
242 it('empty (null) children', async () => {
243 // We don't treat empty children different from non-empty ones, which means
244 // they get allocated a slot when generating ids. There's no inherent reason
245 // to do this; Fiber happens to allocate a fiber for null children that
246 // appear in a list, which is not ideal for performance. For the purposes
247 // of id generation, though, what matters is that Fizz and Fiber
248 // are consistent.
249 function App() {
250 return (
251 <>
252 {null}
253 <DivWithId />
254 {null}
255 <DivWithId />
256 </>
257 );
258 }
259
260 await serverAct(async () => {
261 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
262 pipe(writable);
263 });
264 await clientAct(async () => {
265 ReactDOMClient.hydrateRoot(container, <App />);
266 });
267 expect(container).toMatchInlineSnapshot(`
268 <div
269 id="container"
270 >
271 <div
272 id="10"
273 />
274 <div
275 id="100"
276 />
277 </div>
278 `);
279 });
280
281 it('large ids', async () => {
282 // The component in this test outputs a recursive tree of nodes with ids,
283 // where the underlying binary representation is an alternating series of 1s
284 // and 0s. In other words, they are all of the form 101010101.
285 //
286 // Because we use base 32 encoding, the resulting id should consist of
287 // alternating 'a' (01010) and 'l' (10101) characters, except for the the
288 // 'R:' prefix, and the first character after that, which may not correspond
289 // to a complete set of 5 bits.
290 //
291 // Example: _Rclalalalalalalala...:
292 //
293 // We can use this pattern to test large ids that exceed the bitwise
294 // safe range (32 bits). The algorithm should theoretically support ids
295 // of any size.
296
297 function Child({children}) {
298 const id = useId();
299 return <div id={id}>{children}</div>;
300 }
301
302 function App() {
303 let tree = <Child />;
304 for (let i = 0; i < 50; i++) {
305 tree = (
306 <>
307 <Child />
308 {tree}
309 </>
310 );
311 }
312 return tree;
313 }
314
315 await serverAct(async () => {
316 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
317 pipe(writable);
318 });
319 await clientAct(async () => {
320 ReactDOMClient.hydrateRoot(container, <App />);
321 });
322 const divs = container.querySelectorAll('div');
323
324 // Confirm that every id matches the expected pattern
325 for (let i = 0; i < divs.length; i++) {
326 // Example: _Rclalalalalalalala...:
327 expect(divs[i].id).toMatch(/^_R_.(((al)*a?)((la)*l?))*_$/);
328 }
329 });
330
331 it('multiple ids in a single component', async () => {
332 function App() {
333 const id1 = useId();
334 const id2 = useId();
335 const id3 = useId();
336 return `${id1}, ${id2}, ${id3}`;
337 }
338
339 await serverAct(async () => {
340 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
341 pipe(writable);
342 });
343 await clientAct(async () => {
344 ReactDOMClient.hydrateRoot(container, <App />);
345 });
346 // We append a suffix to the end of the id to distinguish them
347 expect(container).toMatchInlineSnapshot(`
348 <div
349 id="container"
350 >
351 _R_0_, _R_0H1_, _R_0H2_
352 </div>
353 `);
354 });
355
356 it('local render phase updates', async () => {
357 function App({swap}) {
358 const [count, setCount] = useState(0);
359 if (count < 3) {
360 setCount(count + 1);
361 }
362 return useId();
363 }
364
365 await serverAct(async () => {
366 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
367 pipe(writable);
368 });
369 await clientAct(async () => {
370 ReactDOMClient.hydrateRoot(container, <App />);
371 });
372 expect(container).toMatchInlineSnapshot(`
373 <div
374 id="container"
375 >
376 _R_0_
377 </div>
378 `);
379 });
380
381 // @gate enableSuspenseList
382 it('Supports SuspenseList (reveal order independent)', async () => {
383 function Baz({id, children}) {
384 return <span id={id}>{children}</span>;
385 }
386
387 function Bar({children}) {
388 const id = useId();
389 return <Baz id={id}>{children}</Baz>;
390 }
391
392 function Foo() {
393 return (
394 <SuspenseList revealOrder="independent">
395 <Bar>A</Bar>
396 <Bar>B</Bar>
397 </SuspenseList>
398 );
399 }
400
401 await serverAct(async () => {
402 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
403 pipe(writable);
404 });
405 expect(container).toMatchInlineSnapshot(`
406 <div
407 id="container"
408 >
409 <span
410 id="_R_1_"
411 >
412 A
413 </span>
414 <span
415 id="_R_2_"
416 >
417 B
418 </span>
419 </div>
420 `);
421
422 await clientAct(async () => {
423 ReactDOMClient.hydrateRoot(container, <Foo />);
424 });
425
426 expect(container).toMatchInlineSnapshot(`
427 <div
428 id="container"
429 >
430 <span
431 id="_R_1_"
432 >
433 A
434 </span>
435 <span
436 id="_R_2_"
437 >
438 B
439 </span>
440 </div>
441 `);
442 });
443
444 // @gate enableSuspenseList
445 it('Supports SuspenseList (reveal order "together")', async () => {
446 function Baz({id, children}) {
447 return <span id={id}>{children}</span>;
448 }
449
450 function Bar({children}) {
451 const id = useId();
452 return <Baz id={id}>{children}</Baz>;
453 }
454
455 function Foo() {
456 return (
457 <SuspenseList revealOrder="together">
458 <Bar>A</Bar>
459 <Bar>B</Bar>
460 </SuspenseList>
461 );
462 }
463
464 await serverAct(async () => {
465 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
466 pipe(writable);
467 });
468 expect(container).toMatchInlineSnapshot(`
469 <div
470 id="container"
471 >
472 <span
473 id="_R_1_"
474 >
475 A
476 </span>
477 <span
478 id="_R_2_"
479 >
480 B
481 </span>
482 </div>
483 `);
484
485 await clientAct(async () => {
486 ReactDOMClient.hydrateRoot(container, <Foo />);
487 });
488
489 expect(container).toMatchInlineSnapshot(`
490 <div
491 id="container"
492 >
493 <span
494 id="_R_1_"
495 >
496 A
497 </span>
498 <span
499 id="_R_2_"
500 >
501 B
502 </span>
503 </div>
504 `);
505 });
506
507 // @gate enableSuspenseList
508 it('Supports SuspenseList (reveal order "forwards")', async () => {
509 function Baz({id, children}) {
510 return <span id={id}>{children}</span>;
511 }
512
513 function Bar({children}) {
514 const id = useId();
515 return <Baz id={id}>{children}</Baz>;
516 }
517
518 function Foo() {
519 return (
520 <SuspenseList revealOrder="forwards" tail="visible">
521 <Bar>A</Bar>
522 <Bar>B</Bar>
523 </SuspenseList>
524 );
525 }
526
527 await serverAct(async () => {
528 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
529 pipe(writable);
530 });
531 expect(container).toMatchInlineSnapshot(`
532 <div
533 id="container"
534 >
535 <span
536 id="_R_1_"
537 >
538 A
539 </span>
540 <span
541 id="_R_2_"
542 >
543 B
544 </span>
545 </div>
546 `);
547
548 await clientAct(async () => {
549 ReactDOMClient.hydrateRoot(container, <Foo />);
550 });
551
552 expect(container).toMatchInlineSnapshot(`
553 <div
554 id="container"
555 >
556 <span
557 id="_R_1_"
558 >
559 A
560 </span>
561 <span
562 id="_R_2_"
563 >
564 B
565 </span>
566 </div>
567 `);
568 });
569
570 // @gate enableSuspenseList
571 it('Supports SuspenseList (reveal order "backwards") with a single child in a list of many', async () => {
572 function Baz({id, children}) {
573 return <span id={id}>{children}</span>;
574 }
575
576 function Bar({children}) {
577 const id = useId();
578 return <Baz id={id}>{children}</Baz>;
579 }
580
581 function Foo() {
582 return (
583 <SuspenseList revealOrder="unstable_legacy-backwards" tail="visible">
584 {null}
585 <Bar>A</Bar>
586 {null}
587 </SuspenseList>
588 );
589 }
590
591 await serverAct(async () => {
592 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
593 pipe(writable);
594 });
595 expect(container).toMatchInlineSnapshot(`
596 <div
597 id="container"
598 >
599 <span
600 id="_R_2_"
601 >
602 A
603 </span>
604 <!-- -->
605 </div>
606 `);
607
608 await clientAct(async () => {
609 ReactDOMClient.hydrateRoot(container, <Foo />);
610 });
611
612 expect(container).toMatchInlineSnapshot(`
613 <div
614 id="container"
615 >
616 <span
617 id="_R_2_"
618 >
619 A
620 </span>
621 <!-- -->
622 </div>
623 `);
624 });
625
626 // @gate enableSuspenseList
627 it('Supports SuspenseList (reveal order "backwards")', async () => {
628 function Baz({id, children}) {
629 return <span id={id}>{children}</span>;
630 }
631
632 function Bar({children}) {
633 const id = useId();
634 return <Baz id={id}>{children}</Baz>;
635 }
636
637 function Foo() {
638 return (
639 <SuspenseList revealOrder="unstable_legacy-backwards" tail="visible">
640 <Bar>A</Bar>
641 <Bar>B</Bar>
642 </SuspenseList>
643 );
644 }
645
646 await serverAct(async () => {
647 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
648 pipe(writable);
649 });
650 expect(container).toMatchInlineSnapshot(`
651 <div
652 id="container"
653 >
654 <span
655 id="_R_1_"
656 >
657 A
658 </span>
659 <span
660 id="_R_2_"
661 >
662 B
663 </span>
664 </div>
665 `);
666
667 // TODO: This is a bug with revealOrder="backwards" in that it hydrates in reverse.
668 await expect(async () => {
669 await clientAct(async () => {
670 ReactDOMClient.hydrateRoot(container, <Foo />);
671 });
672 }).rejects.toThrow(
673 `Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client.`,
674 );
675
676 expect(container).toMatchInlineSnapshot(`
677 <div
678 id="container"
679 >
680 <span
681 id="_r_1_"
682 >
683 A
684 </span>
685 <span
686 id="_r_0_"
687 >
688 B
689 </span>
690 </div>
691 `);
692 });
693
694 it('basic incremental hydration', async () => {
695 function App() {
696 return (
697 <div>
698 <Suspense fallback="Loading...">
699 <DivWithId label="A" />
700 <DivWithId label="B" />
701 </Suspense>
702 <DivWithId label="C" />
703 </div>
704 );
705 }
706
707 await serverAct(async () => {
708 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
709 pipe(writable);
710 });
711 await clientAct(async () => {
712 ReactDOMClient.hydrateRoot(container, <App />);
713 });
714 expect(container).toMatchInlineSnapshot(`
715 <div
716 id="container"
717 >
718 <div>
719 <!--$-->
720 <div
721 id="101"
722 />
723 <div
724 id="1001"
725 />
726 <!--/$-->
727 <div
728 id="10"
729 />
730 </div>
731 </div>
732 `);
733 });
734
735 it('inserting/deleting siblings outside a dehydrated Suspense boundary', async () => {
736 const span = React.createRef(null);
737 function App({swap}) {
738 // Note: Using a dynamic array so these are treated as insertions and
739 // deletions instead of updates, because Fiber currently allocates a node
740 // even for empty children.
741 const children = [
742 <DivWithId key="A" />,
743 swap ? <DivWithId key="C" /> : <DivWithId key="B" />,
744 <DivWithId key="D" />,
745 ];
746 return (
747 <>
748 {children}
749 <Suspense key="boundary" fallback="Loading...">
750 <DivWithId />
751 <span ref={span} />
752 </Suspense>
753 </>
754 );
755 }
756
757 await serverAct(async () => {
758 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
759 pipe(writable);
760 });
761 const dehydratedSpan = container.getElementsByTagName('span')[0];
762 await clientAct(async () => {
763 const root = ReactDOMClient.hydrateRoot(container, <App />);
764 await waitForPaint([]);
765 expect(container).toMatchInlineSnapshot(`
766 <div
767 id="container"
768 >
769 <div
770 id="101"
771 />
772 <div
773 id="1001"
774 />
775 <div
776 id="1101"
777 />
778 <!--$-->
779 <div
780 id="110"
781 />
782 <span />
783 <!--/$-->
784 </div>
785 `);
786
787 // The inner boundary hasn't hydrated yet
788 expect(span.current).toBe(null);
789
790 // Swap B for C
791 root.render(<App swap={true} />);
792 });
793 // The swap should not have caused a mismatch.
794 expect(container).toMatchInlineSnapshot(`
795 <div
796 id="container"
797 >
798 <div
799 id="101"
800 />
801 <div
802 id="CLIENT_GENERATED_ID"
803 />
804 <div
805 id="1101"
806 />
807 <!--$-->
808 <div
809 id="110"
810 />
811 <span />
812 <!--/$-->
813 </div>
814 `);
815 // Should have hydrated successfully
816 expect(span.current).toBe(dehydratedSpan);
817 });
818
819 it('inserting/deleting siblings inside a dehydrated Suspense boundary', async () => {
820 const span = React.createRef(null);
821 function App({swap}) {
822 // Note: Using a dynamic array so these are treated as insertions and
823 // deletions instead of updates, because Fiber currently allocates a node
824 // even for empty children.
825 const children = [
826 <DivWithId key="A" />,
827 swap ? <DivWithId key="C" /> : <DivWithId key="B" />,
828 <DivWithId key="D" />,
829 ];
830 return (
831 <Suspense key="boundary" fallback="Loading...">
832 {children}
833 <span ref={span} />
834 </Suspense>
835 );
836 }
837
838 await serverAct(async () => {
839 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
840 pipe(writable);
841 });
842 const dehydratedSpan = container.getElementsByTagName('span')[0];
843 await clientAct(async () => {
844 const root = ReactDOMClient.hydrateRoot(container, <App />);
845 await waitForPaint([]);
846 expect(container).toMatchInlineSnapshot(`
847 <div
848 id="container"
849 >
850 <!--$-->
851 <div
852 id="101"
853 />
854 <div
855 id="1001"
856 />
857 <div
858 id="1101"
859 />
860 <span />
861 <!--/$-->
862 </div>
863 `);
864
865 // The inner boundary hasn't hydrated yet
866 expect(span.current).toBe(null);
867
868 // Swap B for C
869 root.render(<App swap={true} />);
870 });
871 // The swap should not have caused a mismatch.
872 expect(container).toMatchInlineSnapshot(`
873 <div
874 id="container"
875 >
876 <!--$-->
877 <div
878 id="101"
879 />
880 <div
881 id="CLIENT_GENERATED_ID"
882 />
883 <div
884 id="1101"
885 />
886 <span />
887 <!--/$-->
888 </div>
889 `);
890 // Should have hydrated successfully
891 expect(span.current).toBe(dehydratedSpan);
892 });
893
894 it('identifierPrefix option', async () => {
895 function Child() {
896 const id = useId();
897 return <div>{id}</div>;
898 }
899
900 function App({showMore}) {
901 return (
902 <>
903 <Child />
904 <Child />
905 {showMore && <Child />}
906 </>
907 );
908 }
909
910 await serverAct(async () => {
911 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
912 identifierPrefix: 'custom-prefix-',
913 });
914 pipe(writable);
915 });
916 let root;
917 await clientAct(async () => {
918 root = ReactDOMClient.hydrateRoot(container, <App />, {
919 identifierPrefix: 'custom-prefix-',
920 });
921 });
922 expect(container).toMatchInlineSnapshot(`
923 <div
924 id="container"
925 >
926 <div>
927 _custom-prefix-R_1_
928 </div>
929 <div>
930 _custom-prefix-R_2_
931 </div>
932 </div>
933 `);
934
935 // Mount a new, client-only id
936 await clientAct(async () => {
937 root.render(<App showMore={true} />);
938 });
939 expect(container).toMatchInlineSnapshot(`
940 <div
941 id="container"
942 >
943 <div>
944 _custom-prefix-R_1_
945 </div>
946 <div>
947 _custom-prefix-R_2_
948 </div>
949 <div>
950 _custom-prefix-r_0_
951 </div>
952 </div>
953 `);
954 });
955
956 // https://github.com/vercel/next.js/issues/43033
957 // re-rendering in strict mode caused the localIdCounter to be reset but it the rerender hook does not
958 // increment it again. This only shows up as a problem for subsequent useId's because it affects child
959 // and sibling counters not the initial one
960 it('does not forget it mounted an id when re-rendering in dev', async () => {
961 function Parent() {
962 const id = useId();
963 return (
964 <div>
965 {id} <Child />
966 </div>
967 );
968 }
969 function Child() {
970 const id = useId();
971 return <div>{id}</div>;
972 }
973
974 function App({showMore}) {
975 return (
976 <React.StrictMode>
977 <Parent />
978 </React.StrictMode>
979 );
980 }
981
982 await serverAct(async () => {
983 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
984 pipe(writable);
985 });
986 expect(container).toMatchInlineSnapshot(`
987 <div
988 id="container"
989 >
990 <div>
991 _R_0_
992 <!-- -->
993
994 <div>
995 _R_7_
996 </div>
997 </div>
998 </div>
999 `);
1000
1001 await clientAct(async () => {
1002 ReactDOMClient.hydrateRoot(container, <App />);
1003 });
1004 expect(container).toMatchInlineSnapshot(`
1005 <div
1006 id="container"
1007 >
1008 <div>
1009 _R_0_
1010 <!-- -->
1011
1012 <div>
1013 _R_7_
1014 </div>
1015 </div>
1016 </div>
1017 `);
1018 });
1019 });