Skip to content

Commit 1fc6ce0

Browse files
alexeyr-ci2alexeyr
andauthored
Fix TypeScript issues (#1715)
* Fix some typescript-eslint issues * Give more precise type to Store * Fix promise issues * Use strict TypeScript-ESLint config * Remove unneeded spacing from an error message * Simplify logic * A trivial optimization * Avoid unnecessary await * Make renderOrHydrateComponent async * Make onPageLoaded/Unloaded sync again * Add a check for unmounted state again --------- Co-authored-by: Alexey Romanov <alexey.v.romanov@gmail.com>
1 parent 79b4941 commit 1fc6ce0

17 files changed

+139
-87
lines changed

eslint.config.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ const config = tsEslint.config([
8080
'function-paren-newline': 'off',
8181
'object-curly-newline': 'off',
8282
'no-restricted-syntax': ['error', 'SequenceExpression'],
83+
'no-void': [
84+
'error',
85+
{
86+
allowAsStatement: true,
87+
},
88+
],
8389

8490
'import/extensions': [
8591
'error',
@@ -134,25 +140,37 @@ const config = tsEslint.config([
134140
{
135141
files: ['**/*.ts', '**/*.tsx'],
136142

137-
extends: tsEslint.configs.recommended,
143+
extends: tsEslint.configs.strictTypeChecked,
138144

139145
languageOptions: {
140146
parserOptions: {
141147
projectService: {
142148
allowDefaultProject: ['eslint.config.ts', 'knip.ts'],
149+
// Needed because `import * as ... from` instead of `import ... from` doesn't work in this file
150+
// for some imports.
151+
defaultProject: 'tsconfig.eslint.json',
143152
},
144153
},
145154
},
146155

147156
rules: {
148157
'@typescript-eslint/no-namespace': 'off',
149158
'@typescript-eslint/no-shadow': 'error',
159+
'@typescript-eslint/no-confusing-void-expression': [
160+
'error',
161+
{
162+
ignoreArrowShorthand: true,
163+
},
164+
],
165+
// Too many false positives
166+
'@typescript-eslint/no-unnecessary-condition': 'off',
150167
'@typescript-eslint/no-unused-vars': [
151168
'error',
152169
{
153170
caughtErrorsIgnorePattern: '^_',
154171
},
155172
],
173+
'@typescript-eslint/restrict-template-expressions': 'off',
156174
},
157175
},
158176
// must be the last config in the array

node_package/src/ClientSideRenderer.ts

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable max-classes-per-file */
2-
/* eslint-disable react/no-deprecated -- while we need to support React 16 */
2+
/* eslint-disable react/no-deprecated,@typescript-eslint/no-deprecated -- while we need to support React 16 */
33

44
import * as ReactDOM from 'react-dom';
55
import type { ReactElement } from 'react';
@@ -14,13 +14,13 @@ import { debugTurbolinks } from './turbolinksUtils';
1414

1515
const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';
1616

17-
function delegateToRenderer(
17+
async function delegateToRenderer(
1818
componentObj: RegisteredComponent,
19-
props: Record<string, string>,
19+
props: Record<string, unknown>,
2020
railsContext: RailsContext,
2121
domNodeId: string,
2222
trace: boolean,
23-
): boolean {
23+
): Promise<boolean> {
2424
const { name, component, isRenderer } = componentObj;
2525

2626
if (isRenderer) {
@@ -32,7 +32,7 @@ function delegateToRenderer(
3232
);
3333
}
3434

35-
(component as RenderFunction)(props, railsContext, domNodeId);
35+
await (component as RenderFunction)(props, railsContext, domNodeId);
3636
return true;
3737
}
3838

@@ -81,7 +81,7 @@ class ComponentRenderer {
8181
// This must match lib/react_on_rails/helper.rb
8282
const name = el.getAttribute('data-component-name') || '';
8383
const { domNodeId } = this;
84-
const props = el.textContent !== null ? JSON.parse(el.textContent) : {};
84+
const props = el.textContent !== null ? (JSON.parse(el.textContent) as Record<string, unknown>) : {};
8585
const trace = el.getAttribute('data-trace') === 'true';
8686

8787
try {
@@ -92,7 +92,11 @@ class ComponentRenderer {
9292
return;
9393
}
9494

95-
if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) {
95+
if (
96+
(await delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) ||
97+
// @ts-expect-error The state can change while awaiting delegateToRenderer
98+
this.state === 'unmounted'
99+
) {
96100
return;
97101
}
98102

@@ -163,8 +167,8 @@ You should return a React.Component always for the client side entry point.`);
163167
}
164168

165169
waitUntilRendered(): Promise<void> {
166-
if (this.state === 'rendering') {
167-
return this.renderPromise!;
170+
if (this.state === 'rendering' && this.renderPromise) {
171+
return this.renderPromise;
168172
}
169173
return Promise.resolve();
170174
}
@@ -183,15 +187,18 @@ class StoreRenderer {
183187
}
184188

185189
const name = storeDataElement.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || '';
186-
const props = storeDataElement.textContent !== null ? JSON.parse(storeDataElement.textContent) : {};
190+
const props =
191+
storeDataElement.textContent !== null
192+
? (JSON.parse(storeDataElement.textContent) as Record<string, unknown>)
193+
: {};
187194
this.hydratePromise = this.hydrate(context, railsContext, name, props);
188195
}
189196

190197
private async hydrate(
191198
context: Context,
192199
railsContext: RailsContext,
193200
name: string,
194-
props: Record<string, string>,
201+
props: Record<string, unknown>,
195202
) {
196203
const storeGenerator = await context.ReactOnRails.getOrWaitForStoreGenerator(name);
197204
if (this.state === 'unmounted') {
@@ -204,8 +211,8 @@ class StoreRenderer {
204211
}
205212

206213
waitUntilHydrated(): Promise<void> {
207-
if (this.state === 'hydrating') {
208-
return this.hydratePromise!;
214+
if (this.state === 'hydrating' && this.hydratePromise) {
215+
return this.hydratePromise;
209216
}
210217
return Promise.resolve();
211218
}
@@ -217,26 +224,30 @@ class StoreRenderer {
217224

218225
const renderedRoots = new Map<string, ComponentRenderer>();
219226

220-
export function renderOrHydrateComponent(domIdOrElement: string | Element): ComponentRenderer | undefined {
227+
export function renderOrHydrateComponent(domIdOrElement: string | Element) {
221228
const domId = getDomId(domIdOrElement);
222-
debugTurbolinks(`renderOrHydrateComponent ${domId}`);
229+
debugTurbolinks('renderOrHydrateComponent', domId);
223230
let root = renderedRoots.get(domId);
224231
if (!root) {
225232
root = new ComponentRenderer(domIdOrElement);
226233
renderedRoots.set(domId, root);
227234
}
228-
return root;
235+
return root.waitUntilRendered();
229236
}
230237

231-
export function renderOrHydrateForceLoadedComponents(): void {
232-
const els = document.querySelectorAll(`.js-react-on-rails-component[data-force-load="true"]`);
233-
els.forEach((el) => renderOrHydrateComponent(el));
238+
async function forAllElementsAsync(
239+
selector: string,
240+
callback: (el: Element) => Promise<void>,
241+
): Promise<void> {
242+
const els = document.querySelectorAll(selector);
243+
await Promise.all(Array.from(els).map(callback));
234244
}
235245

236-
export function renderOrHydrateAllComponents(): void {
237-
const els = document.querySelectorAll(`.js-react-on-rails-component`);
238-
els.forEach((el) => renderOrHydrateComponent(el));
239-
}
246+
export const renderOrHydrateForceLoadedComponents = () =>
247+
forAllElementsAsync('.js-react-on-rails-component[data-force-load="true"]', renderOrHydrateComponent);
248+
249+
export const renderOrHydrateAllComponents = () =>
250+
forAllElementsAsync('.js-react-on-rails-component', renderOrHydrateComponent);
240251

241252
function unmountAllComponents(): void {
242253
renderedRoots.forEach((root) => root.unmount());
@@ -267,15 +278,11 @@ export async function hydrateStore(storeNameOrElement: string | Element) {
267278
await storeRenderer.waitUntilHydrated();
268279
}
269280

270-
export async function hydrateForceLoadedStores(): Promise<void> {
271-
const els = document.querySelectorAll(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}][data-force-load="true"]`);
272-
await Promise.all(Array.from(els).map((el) => hydrateStore(el)));
273-
}
281+
export const hydrateForceLoadedStores = () =>
282+
forAllElementsAsync(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}][data-force-load="true"]`, hydrateStore);
274283

275-
export async function hydrateAllStores(): Promise<void> {
276-
const els = document.querySelectorAll(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}]`);
277-
await Promise.all(Array.from(els).map((el) => hydrateStore(el)));
278-
}
284+
export const hydrateAllStores = () =>
285+
forAllElementsAsync(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}]`, hydrateStore);
279286

280287
function unmountAllStores(): void {
281288
storeRenderers.forEach((storeRenderer) => storeRenderer.unmount());

node_package/src/ComponentRegistry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type RegisteredComponent, type ReactComponentOrRenderFunction, type RenderFunction } from './types';
1+
import { type RegisteredComponent, type ReactComponentOrRenderFunction } from './types';
22
import isRenderFunction from './isRenderFunction';
33
import CallbackRegistry from './CallbackRegistry';
44

@@ -20,7 +20,7 @@ export default {
2020
}
2121

2222
const renderFunction = isRenderFunction(component);
23-
const isRenderer = renderFunction && (component as RenderFunction).length === 3;
23+
const isRenderer = renderFunction && component.length === 3;
2424

2525
componentRegistry.set(name, {
2626
name,

node_package/src/ReactOnRails.client.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,12 @@ if (ctx === undefined) {
2525
}
2626

2727
if (ctx.ReactOnRails !== undefined) {
28-
throw new Error(`
29-
The ReactOnRails value exists in the ${ctx} scope, it may not be safe to overwrite it.
30-
31-
This could be caused by setting Webpack's optimization.runtimeChunk to "true" or "multiple," rather than "single." Check your Webpack configuration.
32-
33-
Read more at https://github.com/shakacode/react_on_rails/issues/1558.
34-
`);
28+
/* eslint-disable @typescript-eslint/no-base-to-string -- Window and Global both have useful toString() */
29+
throw new Error(`\
30+
The ReactOnRails value exists in the ${ctx} scope, it may not be safe to overwrite it.
31+
This could be caused by setting Webpack's optimization.runtimeChunk to "true" or "multiple," rather than "single."
32+
Check your Webpack configuration. Read more at https://github.com/shakacode/react_on_rails/issues/1558.`);
33+
/* eslint-enable @typescript-eslint/no-base-to-string */
3534
}
3635

3736
const DEFAULT_OPTIONS = {
@@ -149,12 +148,12 @@ ctx.ReactOnRails = {
149148
return ClientStartup.reactOnRailsPageLoaded();
150149
},
151150

152-
reactOnRailsComponentLoaded(domId: string): void {
153-
renderOrHydrateComponent(domId);
151+
reactOnRailsComponentLoaded(domId: string): Promise<void> {
152+
return renderOrHydrateComponent(domId);
154153
},
155154

156-
reactOnRailsStoreLoaded(storeName: string): void {
157-
hydrateStore(storeName);
155+
reactOnRailsStoreLoaded(storeName: string): Promise<void> {
156+
return hydrateStore(storeName);
158157
},
159158

160159
/**
@@ -201,11 +200,10 @@ ctx.ReactOnRails = {
201200

202201
/**
203202
* Allows saving the store populated by Rails form props. Used internally by ReactOnRails.
204-
* @param name
205203
* @returns Redux Store, possibly hydrated
206204
*/
207205
setStore(name: string, store: Store): void {
208-
return StoreRegistry.setStore(name, store);
206+
StoreRegistry.setStore(name, store);
209207
},
210208

211209
/**

node_package/src/ReactOnRailsRSC.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const streamRenderRSCComponent = (reactElement: ReactElement, options: RSCRender
4646
});
4747
pipeToTransform(rscStream);
4848
})
49-
.catch((e) => {
49+
.catch((e: unknown) => {
5050
const error = convertToError(e);
5151
renderState.hasErrors = true;
5252
renderState.error = error;

node_package/src/buildConsoleReplay.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function consoleReplay(
3737
val = 'undefined';
3838
}
3939
} catch (e) {
40+
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- if we here, JSON.stringify didn't work
4041
val = `${(e as Error).message}: ${arg}`;
4142
}
4243

node_package/src/clientStartup.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function reactOnRailsPageUnloaded(): void {
1919
unmountAll();
2020
}
2121

22-
export async function clientStartup(context: Context): Promise<void> {
22+
export function clientStartup(context: Context) {
2323
// Check if server rendering
2424
if (!isWindow(context)) {
2525
return;
@@ -34,9 +34,11 @@ export async function clientStartup(context: Context): Promise<void> {
3434
// eslint-disable-next-line no-underscore-dangle
3535
context.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__ = true;
3636

37-
// force loaded components and stores are rendered and hydrated immediately
38-
renderOrHydrateForceLoadedComponents();
39-
hydrateForceLoadedStores();
37+
// Force loaded components and stores are rendered and hydrated immediately.
38+
// The hydration process can handle the concurrent hydration of components and stores,
39+
// so awaiting this isn't necessary.
40+
void renderOrHydrateForceLoadedComponents();
41+
void hydrateForceLoadedStores();
4042

4143
// Other components and stores are rendered and hydrated when the page is fully loaded
4244
onPageLoaded(reactOnRailsPageLoaded);

node_package/src/context.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type Context = Window | typeof globalThis;
1818
/**
1919
* Get the context, be it window or global
2020
*/
21+
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
2122
export default function context(this: void): Context | void {
2223
return (typeof window !== 'undefined' && window) || (typeof global !== 'undefined' && global) || this;
2324
}
@@ -53,7 +54,7 @@ export function getContextAndRailsContext(): { context: Context | null; railsCon
5354
}
5455

5556
try {
56-
currentRailsContext = JSON.parse(el.textContent);
57+
currentRailsContext = JSON.parse(el.textContent) as RailsContext;
5758
} catch (e) {
5859
console.error('Error parsing Rails context:', e);
5960
return { context: null, railsContext: null };

node_package/src/isRenderFunction.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default function isRenderFunction(
1212
component: ReactComponentOrRenderFunction,
1313
): component is RenderFunction {
1414
// No for es5 or es6 React Component
15+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
1516
if ((component as RenderFunction).prototype?.isReactComponent) {
1617
return false;
1718
}
Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
import * as path from 'path';
22
import * as fs from 'fs/promises';
33

4-
const loadedReactClientManifests = new Map<string, Record<string, unknown>>();
4+
type ClientManifest = Record<string, unknown>;
5+
const loadedReactClientManifests = new Map<string, ClientManifest>();
56

67
export default async function loadReactClientManifest(reactClientManifestFileName: string) {
78
// React client manifest is uploaded to node renderer as an asset.
89
// Renderer copies assets to the same place as the server-bundle.js and rsc-bundle.js.
910
// Thus, the __dirname of this code is where we can find the manifest file.
1011
const manifestPath = path.resolve(__dirname, reactClientManifestFileName);
11-
if (!loadedReactClientManifests.has(manifestPath)) {
12-
// TODO: convert to async
13-
try {
14-
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
15-
loadedReactClientManifests.set(manifestPath, manifest);
16-
} catch (error) {
17-
throw new Error(`Failed to load React client manifest from ${manifestPath}: ${error}`);
18-
}
12+
const loadedReactClientManifest = loadedReactClientManifests.get(manifestPath);
13+
if (loadedReactClientManifest) {
14+
return loadedReactClientManifest;
1915
}
2016

21-
return loadedReactClientManifests.get(manifestPath)!;
17+
try {
18+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as ClientManifest;
19+
loadedReactClientManifests.set(manifestPath, manifest);
20+
return manifest;
21+
} catch (error) {
22+
throw new Error(`Failed to load React client manifest from ${manifestPath}: ${error}`);
23+
}
2224
}

0 commit comments

Comments
 (0)