Просмотр исходного кода

fix(spoolbuddy): resolve react-simple-keyboard interop default so the kiosk keyboard renders (#2616)

Focusing any text field on a SpoolBuddy screen (inventory Search, or the
Search / Color Name / Brand fields on write-tag New Spool) blanked the UI
with React error #130 ("Element type is invalid ... but got: object"). It hit
both internal and Spoolman inventories, so it was not data-specific.

The SpoolBuddy shell mounts VirtualKeyboard, an on-screen keyboard that pops up
on focusin for any input -- so every field on every SpoolBuddy page tripped it,
while the main app (no on-screen keyboard) was fine. VirtualKeyboard imports the
default export of react-simple-keyboard, a CommonJS package; under the current
bundler's CJS->ESM interop that default resolves to the module namespace object
({ KeyboardReact, default }) rather than the component, so <Keyboard> renders an
object as an element type and React throws. vitest's interop returns the real
component, so it only manifested in the browser build -- a runtime, not a type,
problem.

Add a small resolveInteropDefault helper that unwraps such an interop-wrapped
default: it returns the value as-is when already a usable element type
(function/class, tag string, or a $$typeof-marked forwardRef/memo/lazy) and
otherwise falls through to .default and named exports. VirtualKeyboard resolves
the real component through it.
maziggy 1 месяц назад
Родитель
Сommit
585b1be054

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 43 - 0
frontend/src/__tests__/components/VirtualKeyboard.test.tsx

@@ -0,0 +1,43 @@
+/**
+ * Regression for #2616. react-simple-keyboard ships as CommonJS; under the
+ * bundler's CJS interop the default import can arrive as the module namespace
+ * object rather than the Keyboard component, so rendering <Keyboard> throws
+ * React #130 ("Element type is invalid ... got: object"). The on-screen keyboard
+ * mounts on every SpoolBuddy screen the instant a text input is focused, so the
+ * crash hit inventory search and the write-tag New Spool fields alike.
+ */
+
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { VirtualKeyboard } from '../../components/VirtualKeyboard';
+
+// focusin schedules a 100ms scrollIntoView on the focused input; jsdom doesn't
+// implement it, so stub it or the deferred call throws an unhandled error after
+// the test completes.
+beforeEach(() => {
+  Element.prototype.scrollIntoView = vi.fn();
+});
+
+afterEach(() => {
+  cleanup();
+  vi.restoreAllMocks();
+});
+
+describe('VirtualKeyboard (#2616)', () => {
+  it('renders the keyboard when a text input is focused (no invalid-element-type crash)', () => {
+    render(
+      <div>
+        <input type="text" placeholder="Search spools..." />
+        <VirtualKeyboard />
+      </div>,
+    );
+
+    const input = screen.getByPlaceholderText('Search spools...');
+    // The shell listens on document focusin, so drive a real focus event.
+    fireEvent.focusIn(input);
+
+    // A key from the layout must be on screen — proves <Keyboard> resolved to a
+    // real component instead of throwing on an object element type.
+    expect(screen.getByText('q')).toBeInTheDocument();
+  });
+});

+ 46 - 0
frontend/src/__tests__/utils/interopDefault.test.ts

@@ -0,0 +1,46 @@
+/**
+ * Unit tests for resolveInteropDefault (#2616).
+ *
+ * The browser build resolved react-simple-keyboard's CommonJS default import to
+ * the module namespace object ({ KeyboardReact, default }) instead of the
+ * component, so <Keyboard> threw React #130 ("got: object"). vitest's own interop
+ * happens to hand back the component, so a render test can't catch the
+ * regression — these assert the resolver directly against both shapes.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { resolveInteropDefault } from '../../utils/interopDefault';
+
+const Comp = function Keyboard() {
+  return null;
+};
+
+describe('resolveInteropDefault', () => {
+  it('returns a bare function component unchanged', () => {
+    expect(resolveInteropDefault(Comp)).toBe(Comp);
+  });
+
+  it('unwraps the CJS interop namespace object via .default (the #2616 shape)', () => {
+    const moduleObject = { default: Comp, KeyboardReact: Comp };
+    expect(resolveInteropDefault(moduleObject, ['KeyboardReact'])).toBe(Comp);
+  });
+
+  it('falls back to a named export when there is no .default', () => {
+    const moduleObject = { KeyboardReact: Comp };
+    expect(resolveInteropDefault(moduleObject, ['KeyboardReact'])).toBe(Comp);
+  });
+
+  it('leaves a forwardRef/memo object (with $$typeof) untouched', () => {
+    const forwardRefLike = { $$typeof: Symbol.for('react.forward_ref'), render: Comp };
+    expect(resolveInteropDefault(forwardRefLike)).toBe(forwardRefLike);
+  });
+
+  it('returns a string tag unchanged', () => {
+    expect(resolveInteropDefault('div')).toBe('div');
+  });
+
+  it('returns the value unchanged when nothing usable is found', () => {
+    const opaque = { something: 1 };
+    expect(resolveInteropDefault(opaque, ['KeyboardReact'])).toBe(opaque);
+  });
+});

+ 14 - 1
frontend/src/components/VirtualKeyboard.tsx

@@ -1,7 +1,20 @@
 import { useEffect, useRef, useState, useCallback } from 'react';
-import Keyboard from 'react-simple-keyboard';
+import KeyboardImport from 'react-simple-keyboard';
 import 'react-simple-keyboard/build/css/index.css';
 import './VirtualKeyboard.css';
+import { resolveInteropDefault } from '../utils/interopDefault';
+
+// react-simple-keyboard is published as CommonJS. Depending on the bundler's
+// CJS->ESM interop, the default import arrives either as the Keyboard component
+// itself or as the module namespace object ({ KeyboardReact, default }). Under
+// the current Vite build (and Node's ESM loader) it's the latter, so rendering
+// <KeyboardImport> puts an object where an element type belongs and React throws
+// "Element type is invalid ... got: object" (#130) — crashing every SpoolBuddy
+// screen the instant a text input is focused and this keyboard mounts (#2616).
+// Resolve the real component defensively so it renders under any interop shape.
+// The TYPE of the default import is already the component (from the .d.ts), so
+// the cast keeps JSX + ref typing intact while fixing only the runtime value.
+const Keyboard = resolveInteropDefault<typeof KeyboardImport>(KeyboardImport, ['KeyboardReact']);
 
 const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url', 'number']);
 

+ 39 - 0
frontend/src/utils/interopDefault.ts

@@ -0,0 +1,39 @@
+/**
+ * Unwrap a default import that a bundler's CommonJS->ESM interop may have
+ * wrapped in a module namespace object.
+ *
+ * Some CommonJS packages set `module.exports = { default: X, Named: X }`.
+ * Depending on the bundler (and differing between the browser build, the test
+ * runner, and Node's own ESM loader), `import X from 'pkg'` can hand you that
+ * whole object instead of `X`. Rendering such an object as a React component
+ * throws "Element type is invalid ... got: object" (React error #130) — see
+ * #2616, where react-simple-keyboard's default import arrived as the namespace
+ * object and crashed every SpoolBuddy screen on input focus.
+ *
+ * This returns the value unchanged when it is already a usable React element
+ * type (a function/class component, a tag string, or an object carrying a React
+ * `$$typeof` marker such as forwardRef/memo/lazy). Otherwise it tries `.default`
+ * and then each of `fallbackKeys` in order, returning the first usable one, and
+ * finally falls back to the original value.
+ */
+export function resolveInteropDefault<T = unknown>(value: unknown, fallbackKeys: string[] = []): T {
+  if (isRenderableType(value)) return value as T;
+
+  if (value !== null && typeof value === 'object') {
+    const obj = value as Record<string, unknown>;
+    if (isRenderableType(obj.default)) return obj.default as T;
+    for (const key of fallbackKeys) {
+      if (isRenderableType(obj[key])) return obj[key] as T;
+    }
+  }
+
+  return value as T;
+}
+
+/** True when `v` is something React can render as an element type. */
+function isRenderableType(v: unknown): boolean {
+  if (typeof v === 'function' || typeof v === 'string') return true;
+  // forwardRef / memo / lazy / context objects are valid element types and are
+  // distinguished from a plain interop wrapper by their React `$$typeof` marker.
+  return typeof v === 'object' && v !== null && '$$typeof' in v;
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Cqi3-E-p.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BLUpUiDA.js"></script>
+    <script type="module" crossorigin src="/assets/index-Cqi3-E-p.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов