Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/core/src/cells/number-cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { drawTextCell, prepTextCell } from "../internal/data-grid/render/data-gr
import { GridCellKind, type NumberCell } from "../internal/data-grid/data-grid-types.js";
import type { InternalCellRenderer } from "./cell-types.js";
import { drawEditHoverIndicator } from "../internal/data-grid/render/draw-edit-hover-indicator.js";
import { preloadableLazy } from "../common/preloadable-lazy.js";

const NumberOverlayEditor = React.lazy(
// Stays in its own chunk. Drawing a number cell preloads it, so a grid that never shows
// one never fetches it, and one that does has it before the cell can be edited.
const { Component: NumberOverlayEditor, preload: preloadNumberOverlayEditor } = preloadableLazy(
async () => await import("../internal/data-grid-overlay-editor/private/number-overlay-editor.js")
);

Expand All @@ -17,6 +20,7 @@ export const numberCellRenderer: InternalCellRenderer<NumberCell> = {
useLabel: true,
drawPrep: prepTextCell,
draw: a => {
void preloadNumberOverlayEditor();
const { hoverAmount, cell, ctx, theme, rect, overrideCursor } = a;
const { hoverEffect, displayData, hoverEffectTheme } = cell;

Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/common/preloadable-lazy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as React from "react";

type Loader<P> = () => Promise<{ default: React.ComponentType<P> }>;

export interface PreloadableLazy<P> {
/** Renders the loaded component, or a `React.lazy` wrapper that suspends until it loads. */
readonly Component: React.FunctionComponent<P>;
/**
* Starts loading the component, once. Later calls return the same promise, which
* settles with the load and never rejects. If this load fails, rendering `Component`
* loads again and reports the error the way `React.lazy` does.
*/
readonly preload: () => Promise<void>;
}

/**
* Like `React.lazy`, but an instance that mounts after the component has loaded (after
* `preload()` or an earlier render) renders it directly instead of suspending. React 19
* holds a suspended boundary's content for up to 300 ms after its fallback appears, even
* if the chunk arrives sooner, so a lazy editor that suspends on the first edit swallows
* the keys typed in that time.
*
* Each instance picks the direct component or the `React.lazy` wrapper when it first
* renders and keeps that choice, so its element type never changes and it is never
* remounted because the module arrived. An instance that suspended and was never
* committed picks again when React retries it.
*/
export function preloadableLazy<P extends object>(load: Loader<P>): PreloadableLazy<P> {
let loaded: React.ComponentType<P> | undefined;
let loading: Promise<{ default: React.ComponentType<P> }> | undefined;
let preloading: Promise<void> | undefined;

const start = () => {
if (loading === undefined) {
loading = load().then(
module => {
loaded = module.default;
return module;
},
(error: unknown) => {
loading = undefined;
throw error;
}
);
}
return loading;
};

const preload = () => {
if (preloading === undefined) {
preloading = start().then(
() => undefined,
() => undefined
);
}
return preloading;
};

// `any` because `LazyExoticComponent` adds ref props that a generic `P` can't be checked against.
const Lazy: React.ComponentType<any> = React.lazy(start);

const Component: React.FunctionComponent<P> = props => {
const [type] = React.useState<React.ComponentType<P>>(() => loaded ?? Lazy);
return React.createElement(type, props);
};

return { Component, preload };
}
8 changes: 7 additions & 1 deletion packages/core/src/data-editor/data-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ import { useRowGroupingInner, type RowGroupingOptions } from "./row-grouping.js"
import { useRowGrouping } from "./row-grouping-api.js";
import { useInitialScrollOffset } from "./use-initial-scroll-offset.js";
import type { VisibleRegion } from "./visible-region.js";
import { preloadableLazy } from "../common/preloadable-lazy.js";

const DataGridOverlayEditor = React.lazy(
// Stays in its own chunk; `DataEditor` preloads it on mount so the first edit doesn't suspend.
const { Component: DataGridOverlayEditor, preload: preloadDataGridOverlayEditor } = preloadableLazy(
async () => await import("../internal/data-grid-overlay-editor/data-grid-overlay-editor.js")
);

Expand Down Expand Up @@ -976,6 +978,10 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr

React.useEffect(() => () => abortControllerRef?.current.abort(), []);

React.useEffect(() => {
void preloadDataGridOverlayEditor();
}, []);

const [getCellsForSelection, getCellsForSeletionDirect] = useCellsForSelection(
getCellsForSelectionIn,
getCellContent,
Expand Down
105 changes: 105 additions & 0 deletions packages/core/test/data-editor-first-edit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as React from "react";
import { render, fireEvent, screen, act } from "@testing-library/react";
import { vi, expect, describe, test, beforeEach, afterEach } from "vitest";
import { DataEditor, type DataEditorProps, GridCellKind } from "../src/index.js";
import { basicProps, prep, sendClick, Context, standardBeforeEach, standardAfterEach } from "./test-utils.js";

// #1202: under React 19 a `React.lazy` editor suspends on the first edit and React
// holds its reveal for up to 300 ms, so the keys typed meanwhile are lost. These tests
// run in their own file because the editors' load state is module-level: an earlier
// test that opened an editor would hide the bug.

const loads = vi.hoisted(() => ({ numberEditor: 0, numberEditorDone: false }));

vi.mock("../src/internal/data-grid-overlay-editor/private/number-overlay-editor.js", async importOriginal => {
loads.numberEditor++;
const module = await importOriginal();
loads.numberEditorDone = true;
return module;
});

vi.mock("../src/common/resize-detector", () => {
return {
useResizeDetector: () => ({ ref: undefined, width: 1000, height: 1000 }),
};
});

/**
* Waits for the overlay editor chunk that `DataEditor` preloads on mount. Importing it
* here doesn't make `React.lazy` resolve: without the preload the first edit still suspends.
*/
async function settleOverlayEditor() {
await act(async () => {
await import("../src/internal/data-grid-overlay-editor/data-grid-overlay-editor.js");
await new Promise(resolve => window.setTimeout(resolve, 10));
});
}

const textOnlyProps: DataEditorProps = {
...basicProps,
getCellContent: ([col, row]) => ({
kind: GridCellKind.Text,
allowOverlay: true,
data: `Data: ${col}, ${row}`,
displayData: `${col}, ${row}`,
}),
};

describe("first type-to-edit after load", () => {
beforeEach(() => {
standardBeforeEach();
});

afterEach(() => {
standardAfterEach();
});

// Must run first: every later test draws number cells, which loads the number editor.
test("a grid that draws no number cell doesn't load the number editor", async () => {
vi.useFakeTimers();
render(<DataEditor {...textOnlyProps} />, { wrapper: Context });
prep();
await act(async () => {
await new Promise(resolve => window.setTimeout(resolve, 10));
});

expect(loads.numberEditor).toBe(0);
});

test("a Text cell's first keypress renders the overlay editor in the same act", async () => {
vi.useFakeTimers();
render(<DataEditor {...basicProps} />, { wrapper: Context });
prep();
await settleOverlayEditor();

const canvas = screen.getByTestId("data-grid-canvas");
sendClick(canvas, {
clientX: 300, // Col B
clientY: 36 + 32 + 16, // Row 1 (0 indexed)
});
fireEvent.keyDown(canvas, { keyCode: 74, key: "j" });

const overlay = screen.getByDisplayValue("j");
expect(document.body.contains(overlay)).toBe(true);
});

test("drawing a number cell loads the number editor, so its first keypress renders it in the same act", async () => {
vi.useFakeTimers();
render(<DataEditor {...basicProps} />, { wrapper: Context });
prep();
// Don't import the number editor here: an import racing the preload's can load it twice.
await vi.waitFor(() => expect(loads.numberEditorDone).toBe(true));
await settleOverlayEditor();

const canvas = screen.getByTestId("data-grid-canvas");
sendClick(canvas, {
clientX: 150 + 160 + 170 + 90, // Col D, a Number column
clientY: 36 + 32 + 16, // Row 1 (0 indexed)
});
fireEvent.keyDown(canvas, { keyCode: 49, key: "1" });

const overlay = screen.getByDisplayValue("1");
expect(overlay.tagName).toBe("INPUT");
expect(loads.numberEditor).toBe(1);
});
});
Loading