electron, webpack, typescript: electron-webpack dev findet Module nicht

Ich habe ein (künstliche reduziertes) Repro hier in GitHub abgelegt. Erst einmal handelt es sich um ein relativ normales electron-webpack-ts Projekt, allerdings mit einer Besonderheit: in einem Unterverzeichnis lib (im realen Szenario ein ganzes Repository als GIT submodule) ist eine Art Bibliothek eingebunden.

In dieser Bibliothek sind (aus historischen Gründen) die Typescript Deklarationen (typings) von den eigentlichen Quellen (src) getrennt. Eine tsconfig.json stellt sicher, dass die Bibliothek auch als eigenständiges Repository gebaut und insbesondere getestet werden kann – nicht im Repro Szenario, da fehlt alles außer dieser einen Konfigurationsdatei und selbst die habe ich auf das unmittelbar Notwendige reduziert. Auf die Struktur der Bibliothek habe ich keinen Einfluss, da diese in vielen anderen Projekten so wie sie ist erwartet wird.

Im eigentlichen Code (renderer) wird eine Methode aus der Bibliothek verwendet.

import * as React from "react"
import { render } from "react-dom"
import { createTest } from "lib"

render(
    <div>[HELLO WORLD]</div>,
    document.querySelector('body > div#app')
)

export const test2 = createTest()

Damit das Modul lib gefunden wird, werden die Typdeklarationen über die tsconfig.json eingebunden, nichts Besonderes soweit.

{
    "extends": "./node_modules/electron-webpack/tsconfig-base.json",
    "compilerOptions": {
        "jsx": "react"
    },
    "include": [
        "lib/typings/**/*.d.ts",
        "src/**/*.ts",
        "src/**/*.tsx"
    ]
}

Durch die Trennung von Code und Deklaration in der Bibliothek reicht das für webpack natürlich nicht für das Bundling, und wir müssen in der package.json eine Anpassung der webpack Konfiguration aktivieren.

    "electronWebpack": {
        "renderer": {
            "webpackConfig": "webpack.override.js"
        }
    },

In der webpack.override.js wird ein Alias für lib definiert.

    config.resolve = { ...config.resolve }

    config.resolve.alias = {
        ...config.resolve.alias,
        'lib': resolve(__dirname, 'lib/src'),
    }

Damit sollten wir eigentlich fertig sein und auch die exotische Struktur der Bibliothek nutzen können. In der Tat läuft auch electron-webpack sauber durch. Beim Starten der Entwicklungsumgebung mit electron-webpack dev gibt es aber leider eine böse Überraschung:

  ERROR in ./lib/src/createTest.ts
  Module not found: Error: Can't resolve 'api' in '.../electron-tsloader-watch-problem/lib/src'
   @ ./lib/src/createTest.ts 1:0-29 2:34-40
   @ ./lib/src/index.ts
   @ ./src/renderer/index.tsx
   @ multi css-hot-loader/hotModuleReplacement ./src/renderer/index.tsx

Warum gibt es plötzlich das Modul nicht mehr? Die Ursache ist wohl, dass zum schnelleren Start bei dev der ts-loader mit transpileOnly true verwendet wird. Meine Vermutung ist, dass hier die Modulreferenzen nicht vollständig aufgelöst werden und der folgende Übersetzungsvorgang (babel denke ich) im hot-reloader diese dann nicht mehr findet.

import { helper, ISomeTest } from "api"

export function createTest(data = helper.one): ISomeTest {
    return { data }
}

Interessant ist, dass der Effekt nicht auftritt, wenn nur Interfaces (hier ISomeTest) referenziert werden, nicht aber zum Beispiel eine enum (hier helper).

Den einfachsten Fix den ich gefunden habe, ist transpileOnly false zu erzwingen – was allerdings den Startvorgang in der Entwicklungsumgebung merklich verzögert. Die Implementierung dazu findet sich wieder in der webpack.override.js.

    const ts = config.module.rules
        .map((r) => r.use)
        .map((u) => Array.isArray(u) && u[0])
        .filter((p) => p && p.loader === 'ts-loader')
        .map((l) => l.options)[0]

    if (ts && ts.transpileOnly && process.env.APPLYWORKAROUND) {
        ts.transpileOnly = false
    }

Setzt man vor dem Aufruf von electron-webpack dev die Umgebungsvariable APPLYWORKAROUND auf irgendwas (e.g. yes) dann läuft dieser wie gewünscht durch.

Der Work-Around ist aber mehr als hässlich und stark von der konkreten Implementierung im electron-webpack abhängig. Eine richtige Lösung etwa in Form einer Konfiguration in der package.json wäre sicher schöner, aber immerhin hilft es in diesem besonderen Fall auch mit dieser Legacy Bibliothek arbeiten zu können.

Vielleicht erspart der Tipp dem einen oder anderen eine längere Suche – ich fand das nicht wirklich einfach zu lokalisieren und zu verstehen. Ich kann mir vorstellen, dass ähnliche Effekte vielleicht auch in weniger exotischen Anwendungen auftreten.

So long

Jochen

react-calendar vs. MobX

Wir verwenden den react-calender (3.4.0) im Zusammenspiel mit MobX (6.3.1) / mobx-react (7.2.0) und nach langem erfolgreichen Einsatz gibt es eine Kleinigkeit, die etwas überrascht hat. Hier eine kurze Beschreibung der Hintergründe und meine aktuelle Lösungsidee – nicht sehr elegant, aber aktuell einfach ein Nebenschauplatz. Das storybook als Beispiel habe ich unten angehängt (TypeScript).

Started man die Story, so wird in jede Zelle des Kalenders ein zusätzlicher Text eingeblendet.

Ändert man allerdings im Eingabefeld den Wert, so tut sich im Kalender nichts.
Wie kann das sein? Ich gebe hier mal meine Interpretation ab:
  • Im Beispiel (wie auch im realen Projekt) werden die Daten als observable Objekt (data) durchgereicht.
  • Für die Eingabe (InputTest im Beispiel) funktioniert das auch wie gewünscht / immer.
  • Der eigentlich wichtige Komponente (CalendarTest) ignoriert das aber.

Warum könnte das sein? Nun tatsächlich ist es ja der entscheidende Gag von MobX dass sich in der Tat data gar nicht ändert, sondern immer ein identischer Proxy auf die eigentlichen Daten ist. Erst der Einsatz des observer Dekorators sorgt dafür, dass Datenzugriffe in der render Methode vermerkt werden und Änderungen sehr detailliert zu Aktualisierungen führen. Aber hier passiert nichts, obwohl doch “offensichtlich” auf props.data.text zugegriffen wird, um den zusätzliche Inhalt anzuzeigen.

Mein aktueller Kenntnisstand dazu: die Kalender Komponente erzeugt die Zellen (und damit auch den tileContent) nicht im render, sondern im static getDerivedStateFromProps – und tatsächlich haben sich aus Sicht von React die props ja gar nicht verändert! Ich vermute es handelt sich hier um eine Optimierung, die auch fast immer greift – bis so High Tech Lösungen via MobX eine Änderungsüberwachung jenseits von React einbringen.

Ich habe mich für den Moment entschieden, einen Work-Around zu verwenden – eine für fast alle Anwender von react-calendar traurige Lösung wäre auf das getDerivedStateFromProps zu verzichten, eine andere im MobX durch den observer auch getDerivedStateFromProps überwachen zu lassen, da gibt es wohl schon ähnliche Diskussionen in den Issues.

Als Lösung gibt man der Kalender Komponente einfach eine zusätzliche key Eigenschaft, die sich immer verändert, wenn die Zellen neu erstellt werden sollen – natürlich nicht wie hier manuell über eine Schaltfläche sondern elegant über eine MobX computed Eigenschaft, aber das Detail erspare ich mir hier einfach, jeder der MobX verwendet weiß sicher, was ich meine.

Drückt man in der Story die Schaltfläche, so wird über die veränderte Eigenschaft die Kalender Komponte gezwungen, ihr getDerivedStateFromProps neu aufzubauen – et voilà.

Have Fun!

Jochen

import { withKnobs } from '@storybook/addon-knobs'
import { observable } from 'mobx'
import { observer } from 'mobx-react'
import * as React from 'react'
import Calendar from 'react-calendar'

export default { decorators: [withKnobs], title: 'Fehler et al/Kalendar Tile Aktualisierung' }

interface IEntity {
    text: string
}

interface ICalendarTestProps {
    data: IEntity
    key: number
}

const CalendarTest = observer(
    (props: ICalendarTestProps): JSX.Element => (
        <Calendar
            key={props.key}
            closeCalendar={false}
            tileContent={() => <div>{props.data.text}</div>}
            value={null}
            view='month'
        />
    )
)

interface IInputTestTestProps {
    data: IEntity
}

const InputTest = observer(
    (props: IInputTestTestProps): JSX.Element => (
        <input type='text' value={props.data.text} onChange={(ev) => (props.data.text = ev.target.value)} />
    )
)

export const standard = ((): React.ReactNode => {
    const data = React.useMemo<IEntity>(() => observable({ text: 'C' }), [])
    const [key, setKey] = React.useState(0)

    return (
        <div>
            <button onClick={() => setKey(key + 1)}>UPDATE KEY</button>
            <InputTest data={data} />
            <CalendarTest key={key} data={data} />
        </div>
    )
}) as React.FC

Mehr Spaß mit TypeScript: Klassenschnittstellen

Man kann in TypeScript ja über die new() Funktion Konstruktoren insbesondere auch in Schnittstellen referenzieren. Schauen wir uns dazu einmal folgendes Beispiel an:

interface IAnyFactory<TAny> {
new (param: string): TAny;
greeting: string;
}

class Any {
static greeting = "Hi";
constructor(private _param: string) {}
}

function create<TAny>(factory: IAnyFactory<TAny>): TAny { return new factory("test"); }
function testAny(): Any { return create(Any); }
Ich kann an die Funktion create erst einmal irgendeine Klasse übergeben, wo dann tatsächlich der Konstruktor als Parameter verwendet wird. Alle statischen Felder und Methoden einer Klasse sind aber in JavaScript einfach nur die Eigenschaften dieser Funktion. Über die Schnittstelle IAnyFactory verlangt create, dass nur solche Klassen verwendet werden dürfen, die zusätzlich auch ein statisches Feld greeting der Art string haben. Ich kann mir vorstellen, dass dieses Verhalten ganz praktisch sein kann: man hat hier quasi so etwas wie eine Schnittstellendefinition für eine Klasse!

Happy Coding

Jochen

Optionale Parameter in Methoden von TypeScript Schnittstellen

Wie immer: wenn man nachher darüber nachdenkt ist alles klar, aber manchmal ist man dann einen Moment lang doch überrascht. Nehmen wir einmal folgende TypeScript Schnittstelle:

interface ISample {
do(name?: string): void;
}

Die Methode do kann nun mit oder ohne Parameter aufgerufen werden:

extra(test: ISample): void {
test.do();
test.do(undefined);
test.do('ho');
}

Um in der Implementierung festzustellen ob ein Parameter übergeben wurde verwendet man im Allgemeinen einen Vergleich gegen undefined (oder alternativ void 0) – tatsächlich müsste man arguments.length zu Rate ziehen, um die ersten beiden Varianten sicher unterscheiden zu können. Das kann TypeScript einem aber auch abnehmen:

do(name: string = 'default'): void {
}

Genau dieser Vergleich auf undefined wird hier (ohne Rücksicht auf arguments.length, i.e. die beiden ersten Aufrufvarianten werden gleich behandelt) durchgeführt und der Parameter entsprechend belegt.

Alles das war mir im Einzelnen schon klar. Schön ist aber bei genauer Betrachtung, dass im Gegensatz zu Default Werten für Parametern in C# et al nicht der Aufrufer sondern die Implementierung entscheidet, was für ein Default verwendet werden soll. In meinem konkreten Anwendungsfall gibt es dann tatsächlich unterschiedliche Implementierungen der Schnittstelle mit unterschiedlichen Bedürfnissen, wo sich dieses Verhalten dann als sehr praktisch erwiesen hat.

Netter Seiteneffekt

Jochen

Spass mit CSS – eine Zeitlinie

Ich wollte für mich einmal versuchen, eine kleine Aufgabenstellung ohne Code (JavaScript) nur mit CSS Bordmitteln zu lösen – ok, tatsächlich ist das Beispiel hier aus einem AngularJS Projekt und etwas abstrahiert, aber vielleicht hilft ja schon die Idee. Gegeben ist eine Zeitlinie mit Datumswerten (im Beispiel auf Jahre vereinfacht) mit einer mehr oder weniger langen Beschreibung pro Datum (hier habe ich einfach aus dem deutschen Wiki ein bißchen was zu Windows zusammengesucht, die Links ins Wiki sind natürlich als Quellenreferenz im Beispiel mit drin). Erreichen will ich im Wesentlichen eine vertikale Zeitlinie mit Kurztexten (hier einfach immer nur die erste Zeile des vollen Textes), die man auf Wunsch aufklappen kann (hier ziemlich sinnfrei und dumm über ein CSS :hover gelöst – nicht praxistauglich). Wichtig ist, dass das Ein- und Ausblenden der Langtexte die Zeitlinie ohne Programmcode einigermassen erhält. Also so soll das aussehen:

Zeitlinie

Im HTML sollen dabei aber nur die eigentlichen Daten stehen, möglichst wenig Styling Schnick-Schnack, so was wie das hier:

Daten

Die Lösung sieht man dann hier – einfach mit der Maus über die Texte fahren. Der kleine Knubbel und die Linie sind einfach SVG Hintergründe (backgound-image) im CSS, geschickt positioniert und skaliert. Wird ein Text expandiert, so bleibt der Knubbel stehen und die Linie dehnt sich über die gesamte Höhe aus.

Nett und gar nicht so schwer.

Happy Coding

Jochen