Skip to content

Commit d15ac42

Browse files
committed
Fix hydrate_on scheduling review feedback
1 parent ac30ea9 commit d15ac42

2 files changed

Lines changed: 140 additions & 18 deletions

File tree

packages/react-on-rails/src/ClientRenderer.ts

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ function teardownEntry(entry: RenderedEntry, domNodeId: string): void {
9797
}
9898
}
9999

100+
function teardownErrorLabel(entry: RenderedEntry, domNodeId: string): string {
101+
if (entry.kind === 'renderer') {
102+
return `Error in renderer teardown for dom node "${domNodeId}":`;
103+
}
104+
105+
if (entry.kind === 'scheduled') {
106+
return `Error canceling scheduled render for dom node "${domNodeId}":`;
107+
}
108+
109+
return `Error unmounting component for dom node "${domNodeId}":`;
110+
}
111+
100112
function initializeStore(el: Element, railsContext: RailsContext): void {
101113
const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || '';
102114
const props = el.textContent !== null ? (JSON.parse(el.textContent) as Record<string, unknown>) : {};
@@ -130,6 +142,9 @@ function hydrateOnForEl(el: Element): HydrateOnMode {
130142

131143
function scheduleWhenVisible(domNode: Element, callback: () => void): () => void {
132144
if (typeof IntersectionObserver === 'undefined') {
145+
console.warn(
146+
'[react-on-rails] IntersectionObserver is not available. Falling back to immediate hydration for hydrate_on: :visible.',
147+
);
133148
const timeoutId = window.setTimeout(callback, 0);
134149
return () => window.clearTimeout(timeoutId);
135150
}
@@ -166,7 +181,7 @@ function scheduleWhenIdle(callback: () => void): () => void {
166181
};
167182
}
168183

169-
const timeoutId = window.setTimeout(callback, 1);
184+
const timeoutId = window.setTimeout(callback, 50);
170185
return () => window.clearTimeout(timeoutId);
171186
}
172187

@@ -183,11 +198,19 @@ function scheduleHydration(hydrateOn: HydrateOnMode, domNode: Element, callback:
183198
return () => {};
184199
}
185200

186-
function raiseRenderError(componentName: string, error: unknown): never {
201+
function prepareRenderError(componentName: string, error: unknown): Error {
187202
const renderError = error as Error;
188203
console.error(renderError.message);
189204
renderError.message = `ReactOnRails encountered an error while rendering component: ${componentName}. See above error message.`;
190-
throw renderError;
205+
return renderError;
206+
}
207+
208+
function raiseRenderError(componentName: string, error: unknown): never {
209+
throw prepareRenderError(componentName, error);
210+
}
211+
212+
function reportRenderError(componentName: string, error: unknown): void {
213+
console.error(prepareRenderError(componentName, error).message);
191214
}
192215

193216
// Result of attempting renderer delegation. Core carries the raw RendererResult (which may still be
@@ -339,11 +362,7 @@ function renderElement(el: Element, railsContext: RailsContext): void {
339362
// Surface the failure unconditionally (matching unmountAllComponents) so a teardown/unmount
340363
// error on node replacement is as visible as one on page unload, using the same greppable
341364
// labels. We still continue: the old mount may leak, but the new node must be rendered.
342-
const label =
343-
existing.kind === 'renderer'
344-
? `Error in renderer teardown for dom node "${domNodeId}":`
345-
: `Error unmounting component for dom node "${domNodeId}":`;
346-
console.error(label, unmountError);
365+
console.error(teardownErrorLabel(existing, domNodeId), unmountError);
347366
}
348367
renderedRoots.delete(domNodeId);
349368
}
@@ -411,7 +430,7 @@ You should return a React.Component always for the client side entry point.`);
411430
mountReactRoot();
412431
} catch (scheduledError) {
413432
renderedRoots.delete(domNodeId);
414-
raiseRenderError(name, scheduledError);
433+
reportRenderError(name, scheduledError);
415434
}
416435
};
417436

@@ -486,15 +505,7 @@ function unmountAllComponents(): void {
486505
try {
487506
teardownEntry(entry, domNodeId);
488507
} catch (error) {
489-
// Use the same label as the async-rejection path so renderer-teardown failures are greppable
490-
// whether the teardown threw synchronously (here) or rejected (invokeRendererTeardown).
491-
let label = `Error unmounting component for dom node "${domNodeId}":`;
492-
if (entry.kind === 'renderer') {
493-
label = `Error in renderer teardown for dom node "${domNodeId}":`;
494-
} else if (entry.kind === 'scheduled') {
495-
label = `Error canceling scheduled render for dom node "${domNodeId}":`;
496-
}
497-
console.error(label, error);
508+
console.error(teardownErrorLabel(entry, domNodeId), error);
498509
}
499510
});
500511
renderedRoots.clear();

packages/react-on-rails/tests/ClientRenderer.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,117 @@ describe('ClientRenderer', () => {
800800

801801
expect(mockHydrateOrRender).not.toHaveBeenCalled();
802802
});
803+
804+
it('reports scheduled render errors without throwing from the deferred callback', () => {
805+
type ObserverCallback = ConstructorParameters<typeof IntersectionObserver>[0];
806+
let observerCallback!: ObserverCallback;
807+
const renderError = new Error('scheduled render boom');
808+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
809+
const mockHydrateOrRender = require('../src/reactHydrateOrRender.ts').default as jest.Mock;
810+
mockHydrateOrRender.mockImplementationOnce(() => {
811+
throw renderError;
812+
});
813+
814+
class MockIntersectionObserver {
815+
disconnect = jest.fn();
816+
817+
observe = jest.fn();
818+
819+
constructor(callback: ObserverCallback) {
820+
observerCallback = callback;
821+
}
822+
}
823+
824+
Object.defineProperty(globalThis, 'IntersectionObserver', {
825+
configurable: true,
826+
value: MockIntersectionObserver,
827+
});
828+
829+
setupScheduledComponentDom('hydrate-visible-error', 'visible');
830+
renderComponent('hydrate-visible-error');
831+
832+
expect(() => {
833+
observerCallback(
834+
[{ isIntersecting: true, intersectionRatio: 1 }] as IntersectionObserverEntry[],
835+
{} as IntersectionObserver,
836+
);
837+
}).not.toThrow();
838+
expect(consoleErrorSpy).toHaveBeenCalledWith('scheduled render boom');
839+
expect(consoleErrorSpy).toHaveBeenCalledWith(
840+
'ReactOnRails encountered an error while rendering component: TestComponent. See above error message.',
841+
);
842+
consoleErrorSpy.mockRestore();
843+
});
844+
845+
it('warns when visible hydration falls back without IntersectionObserver', () => {
846+
jest.useFakeTimers();
847+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
848+
setupScheduledComponentDom('hydrate-visible-fallback', 'visible');
849+
const mockHydrateOrRender = require('../src/reactHydrateOrRender.ts').default as jest.Mock;
850+
851+
renderComponent('hydrate-visible-fallback');
852+
853+
expect(consoleWarnSpy).toHaveBeenCalledWith(
854+
'[react-on-rails] IntersectionObserver is not available. Falling back to immediate hydration for hydrate_on: :visible.',
855+
);
856+
expect(mockHydrateOrRender).not.toHaveBeenCalled();
857+
858+
jest.runOnlyPendingTimers();
859+
expect(mockHydrateOrRender).toHaveBeenCalledTimes(1);
860+
861+
consoleWarnSpy.mockRestore();
862+
jest.useRealTimers();
863+
});
864+
865+
it('uses a short idle fallback delay when requestIdleCallback is unavailable', () => {
866+
jest.useFakeTimers();
867+
setupScheduledComponentDom('hydrate-idle-fallback', 'idle');
868+
const mockHydrateOrRender = require('../src/reactHydrateOrRender.ts').default as jest.Mock;
869+
870+
renderComponent('hydrate-idle-fallback');
871+
872+
jest.advanceTimersByTime(49);
873+
expect(mockHydrateOrRender).not.toHaveBeenCalled();
874+
875+
jest.advanceTimersByTime(1);
876+
expect(mockHydrateOrRender).toHaveBeenCalledTimes(1);
877+
878+
jest.useRealTimers();
879+
});
880+
881+
it('logs scheduled cancellation errors with the scheduled label during node replacement', () => {
882+
const disconnectError = new Error('scheduled disconnect boom');
883+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
884+
885+
class MockIntersectionObserver {
886+
disconnect = jest.fn(() => {
887+
throw disconnectError;
888+
});
889+
890+
observe = jest.fn();
891+
}
892+
893+
Object.defineProperty(globalThis, 'IntersectionObserver', {
894+
configurable: true,
895+
value: MockIntersectionObserver,
896+
});
897+
898+
const targetNode = setupScheduledComponentDom('hydrate-visible-replace-label', 'visible');
899+
renderComponent('hydrate-visible-replace-label');
900+
901+
targetNode.remove();
902+
const replacementNode = document.createElement('div');
903+
replacementNode.id = 'hydrate-visible-replace-label';
904+
document.body.appendChild(replacementNode);
905+
906+
expect(() => renderComponent('hydrate-visible-replace-label')).not.toThrow();
907+
expect(consoleErrorSpy).toHaveBeenCalledWith(
908+
'Error canceling scheduled render for dom node "hydrate-visible-replace-label":',
909+
disconnectError,
910+
);
911+
912+
consoleErrorSpy.mockRestore();
913+
});
803914
});
804915

805916
describe('page unload cleanup (React-root cleanup)', () => {

0 commit comments

Comments
 (0)