2841
expect(window.__test_outlet).toBe(1);
2842
});
2843
});
2844
+
2845
+ // @gate experimental && enableClientRenderFallbackOnTextMismatch
2846
+ it('#24384: Suspending should halt hydration warnings while still allowing siblings to warm up', async () => {
2847
+ const makeApp = () => {
2848
+ let resolve, resolved;
2849
+ const promise = new Promise(r => {
2850
+ resolve = () => {
2851
+ resolved = true;
2852
+ return r();
2853
+ };
2854
+ });
2855
+ function ComponentThatSuspends() {
2856
+ if (!resolved) {
2857
+ throw promise;
2858
+ }
2859
+ return <p>A</p>;
2860
+ }
2861
+
2862
+ const App = ({text}) => {
2863
+ return (
2864
+ <div>
2865
+ <Suspense fallback={<h1>Loading...</h1>}>
2866
+ <ComponentThatSuspends />
2867
+ <h2 name={text}>{text}</h2>
2868
+ </Suspense>
2869
+ </div>
2870
+ );
2871
+ };
2872
+
2873
+ return [App, resolve];
2874
+ };
2875
+
2876
+ const [ServerApp, serverResolve] = makeApp();
2877
+ await act(async () => {
2878
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
2879
+ <ServerApp text="initial" />,
2880
+ );
2881
+ pipe(writable);
2882
+ });
2883
+ await act(() => {
2884
+ serverResolve();
2885
+ });
2886
+
2887
+ expect(getVisibleChildren(container)).toEqual(
2888
+ <div>
2889
+ <p>A</p>
2890
+ <h2 name="initial">initial</h2>
2891
+ </div>,
2892
+ );
2893
+
2894
+ // The client app is rendered with an intentionally incorrect text. The still Suspended component causes
2895
+ // hydration to fail silently (allowing for cache warming but otherwise skipping this boundary) until it
2896
+ // resolves.
2897
+ const [ClientApp, clientResolve] = makeApp();
2898
+ ReactDOMClient.hydrateRoot(container, <ClientApp text="replaced" />, {
2899
+ onRecoverableError(error) {
2900
+ Scheduler.unstable_yieldValue(
2901
+ 'Logged recoverable error: ' + error.message,
2902
+ );
2903
+ },
2904
+ });
2905
+ Scheduler.unstable_flushAll();
2906
+
2907
+ expect(getVisibleChildren(container)).toEqual(
2908
+ <div>
2909
+ <p>A</p>
2910
+ <h2 name="initial">initial</h2>
2911
+ </div>,
2912
+ );
2913
+
2914
+ // Now that the boundary resolves to it's children the hydration completes and discovers that there is a mismatch requiring
2915
+ // client-side rendering.
2916
+ await clientResolve();
2917
+ expect(() => {
2918
+ expect(Scheduler).toFlushAndYield([
2919
+ 'Logged recoverable error: Text content does not match server-rendered HTML.',
2920
+ 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
2921
+ ]);
2922
+ }).toErrorDev(
2923
+ 'Warning: Prop `name` did not match. Server: "initial" Client: "replaced"',
2924
+ );
2925
+ expect(getVisibleChildren(container)).toEqual(
2926
+ <div>
2927
+ <p>A</p>
2928
+ <h2 name="replaced">replaced</h2>
2929
+ </div>,
2930
+ );
2931
+
2932
+ expect(Scheduler).toFlushAndYield([]);
2933
+ });
2934
+
2935
+ // @gate experimental && enableClientRenderFallbackOnTextMismatch
2936
+ it('only warns once on hydration mismatch while within a suspense boundary', async () => {
2937
+ const originalConsoleError = console.error;
2938
+ const mockError = jest.fn();
2939
+ console.error = (...args) => {
2940
+ mockError(...args.map(normalizeCodeLocInfo));
2941
+ };
2942
+
2943
+ const App = ({text}) => {
2944
+ return (
2945
+ <div>
2946
+ <Suspense fallback={<h1>Loading...</h1>}>
2947
+ <h2>{text}</h2>
2948
+ <h2>{text}</h2>
2949
+ <h2>{text}</h2>
2950
+ </Suspense>
2951
+ </div>
2952
+ );
2953
+ };
2954
+
2955
+ try {
2956
+ await act(async () => {
2957
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
2958
+ <App text="initial" />,
2959
+ );
2960
+ pipe(writable);
2961
+ });
2962
+
2963
+ expect(getVisibleChildren(container)).toEqual(
2964
+ <div>
2965
+ <h2>initial</h2>
2966
+ <h2>initial</h2>
2967
+ <h2>initial</h2>
2968
+ </div>,
2969
+ );
2970
+
2971
+ ReactDOMClient.hydrateRoot(container, <App text="replaced" />, {
2972
+ onRecoverableError(error) {
2973
+ Scheduler.unstable_yieldValue(
2974
+ 'Logged recoverable error: ' + error.message,
2975
+ );
2976
+ },
2977
+ });
2978
+ expect(Scheduler).toFlushAndYield([
2979
+ 'Logged recoverable error: Text content does not match server-rendered HTML.',
2980
+ 'Logged recoverable error: Text content does not match server-rendered HTML.',
2981
+ 'Logged recoverable error: Text content does not match server-rendered HTML.',
2982
+ 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
2983
+ ]);
2984
+
2985
+ expect(getVisibleChildren(container)).toEqual(
2986
+ <div>
2987
+ <h2>replaced</h2>
2988
+ <h2>replaced</h2>
2989
+ <h2>replaced</h2>
2990
+ </div>,
2991
+ );
2992
+
2993
+ expect(Scheduler).toFlushAndYield([]);
2994
+ if (__DEV__) {
2995
+ expect(mockError.mock.calls.length).toBe(1);
2996
+ expect(mockError.mock.calls[0]).toEqual([
2997
+ 'Warning: Text content did not match. Server: "%s" Client: "%s"%s',
2998
+ 'initial',
2999
+ 'replaced',
3000
+ '\n' +
3001
+ ' in h2 (at **)\n' +
3002
+ ' in Suspense (at **)\n' +
3003
+ ' in div (at **)\n' +
3004
+ ' in App (at **)',
3005
+ ]);
3006
+ } else {
3007
+ expect(mockError.mock.calls.length).toBe(0);
3008
+ }
3009
+ } finally {
3010
+ console.error = originalConsoleError;
3011
+ }
3012
+ });
3013
+
3014
+ // @gate experimental
3015
+ it('supresses hydration warnings when an error occurs within a Suspense boundary', async () => {
3016
+ let isClient = false;
3017
+ let shouldThrow = true;
3018
+
3019
+ function ThrowUntilOnClient({children}) {
3020
+ if (isClient && shouldThrow) {
3021
+ throw new Error('uh oh');
3022
+ }
3023
+ return children;
3024
+ }
3025
+
3026
+ function StopThrowingOnClient() {
3027
+ if (isClient) {
3028
+ shouldThrow = false;
3029
+ }
3030
+ return null;
3031
+ }
3032
+
3033
+ const App = () => {
3034
+ return (
3035
+ <div>
3036
+ <Suspense fallback={<h1>Loading...</h1>}>
3037
+ <ThrowUntilOnClient>
3038
+ <h1>one</h1>
3039
+ </ThrowUntilOnClient>
3040
+ <h2>two</h2>
3041
+ <h3>{isClient ? 'five' : 'three'}</h3>
3042
+ <StopThrowingOnClient />
3043
+ </Suspense>
3044
+ </div>
3045
+ );
3046
+ };
3047
+
3048
+ await act(async () => {
3049
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
3050
+ pipe(writable);
3051
+ });
3052
+
3053
+ expect(getVisibleChildren(container)).toEqual(
3054
+ <div>
3055
+ <h1>one</h1>
3056
+ <h2>two</h2>
3057
+ <h3>three</h3>
3058
+ </div>,
3059
+ );
3060
+
3061
+ isClient = true;
3062
+
3063
+ ReactDOMClient.hydrateRoot(container, <App />, {
3064
+ onRecoverableError(error) {
3065
+ Scheduler.unstable_yieldValue(
3066
+ 'Logged recoverable error: ' + error.message,
3067
+ );
3068
+ },
3069
+ });
3070
+ expect(Scheduler).toFlushAndYield([
3071
+ 'Logged recoverable error: uh oh',
3072
+ 'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
3073
+ 'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
3074
+ 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
3075
+ ]);
3076
+
3077
+ expect(getVisibleChildren(container)).toEqual(
3078
+ <div>
3079
+ <h1>one</h1>
3080
+ <h2>two</h2>
3081
+ <h3>five</h3>
3082
+ </div>,
3083
+ );
3084
+
3085
+ expect(Scheduler).toFlushAndYield([]);
3086
+ });
3087
});