Skip to content

refactor(flow-client): Migrate from GWT to TypeScript#24703

Draft
Artur- wants to merge 63 commits into
mainfrom
flow-client-jsinterop-exports
Draft

refactor(flow-client): Migrate from GWT to TypeScript#24703
Artur- wants to merge 63 commits into
mainfrom
flow-client-jsinterop-exports

Conversation

@Artur-

@Artur- Artur- commented Jun 20, 2026

Copy link
Copy Markdown
Member

No description provided.

…gration

Turn on the GWT compiler's generateJsInteropExports so classes annotated with
@jstype are published to window.Vaadin.Flow.internal.* and callable from
TypeScript. This is the foundation for migrating flow-client from GWT to
TypeScript top-down: new TypeScript calls into the still-Java GWT bundle through
these exports (TypeScript -> GWT only), so no class is ever implemented twice.

Adds a minimal JsInteropProbe export plus a GwtTest and a web-test-runner test
that prove, in CI, that the export is emitted and callable end to end, and an
internal/gwtExports.ts accessor establishing the typed-access convention for
exported classes. No production code is migrated yet.

See MIGRATION_STRATEGY.md for the full plan.
@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown

Test Results

 1 451 files   - 1   1 451 suites   - 1   1h 26m 22s ⏱️ + 1m 33s
10 260 tests  - 1  10 192 ✅  - 1  68 💤 ±0  0 ❌ ±0 
10 732 runs   - 1  10 663 ✅  - 1  69 💤 ±0  0 ❌ ±0 

Results for commit b8f4a2e. ± Comparison against base commit 4f03bb4.

♻️ This comment has been updated with latest results.

@Artur- Artur- force-pushed the flow-client-jsinterop-exports branch from 676bb39 to 7d6345f Compare June 21, 2026 06:13
Artur- added 3 commits June 21, 2026 12:13
…e TS migration

Annotate ApplicationConnection with @jstype and expose the operations that back
the published window.Vaadin.Flow.clients[appId] object as exported methods
(isActive, getByNodeId, getNodeId, addDomBindingListener, poll, resolveUri,
sendEventMessage, getUIId, connectWebComponent, debug). The two JSNI publication
blocks now delegate to these methods instead of inlining the registry lookups,
so each piece of logic lives in exactly one place.

The constructor is marked @JSignore: exporting a member whose body builds the
engine pulls MessageSender's GWT.create(PushConnectionFactory) rebind into the
JsInterop export pass, which the GWT compiler cannot resolve ("Rebind result
... could not be found"). The instance is created on the GWT side; only the
methods are exported. Behavior is unchanged.
…ration

Annotate ApplicationConfiguration with @jstype so the TypeScript code that will
take over client publication can read the translated application configuration
(production mode, request timing, exported web components, servlet version, UI
id, ...). Purely additive: the getters are already public, behavior is unchanged.
…urface

Add typed interfaces in internal/gwtExports.ts mirroring the exported
ApplicationConnection client API and the ApplicationConfiguration getters. The
ApplicationConnection constructor is intentionally not exported (see @JSignore),
so TypeScript consumes only the methods; the instance is created on the GWT side.
No runtime behavior changes.
@Artur- Artur- force-pushed the flow-client-jsinterop-exports branch from 7d6345f to 218ad2c Compare June 21, 2026 09:43
Artur- added 22 commits June 21, 2026 12:56
… migration

Export the dev/profiling helpers that back the published clients[] object:
getJavaClass, isHiddenByServer and getElementStyleProperties (used by the dev
getNodeInfo), and getProfilingData (moved from the inline JSNI publication into
an exported native method). Add them to the gwtExports TypeScript contract.

Behavior-preserving: the publication now delegates getProfilingData to the new
method; the other helpers were already called by the dev publication block. This
completes the exported client-API surface so the publication of clients[] can be
built in TypeScript.
…bject

Add internal/publishClient.ts, the TypeScript replacement for the JSNI
publishJavascriptMethods / publishDevelopmentModeJavascriptMethods blocks. It
builds window.Vaadin.Flow.clients[appId] by delegating to the exported methods of
the GWT-constructed ApplicationConnection and reads flags from the exported
ApplicationConfiguration.

Not yet wired into the bootstrap: the handoff that calls this with the
constructed instance, and the removal of the JSNI publication, is a separate
step. No runtime behavior changes.
Cover publishClient with fakes for the exported ApplicationConnection and
ApplicationConfiguration: the TestBench-id key (window-name suffix stripped),
core API delegation, the requestTiming getProfilingData branch, and the dev-mode
getVersionInfo / debug / getNodeInfo branch.
Switch the ApplicationConnection constructor to hand itself and its
configuration to the TypeScript publishClient (window.Vaadin.Flow.internal.
publishClient) instead of building window.Vaadin.Flow.clients[appId] in JSNI.
Both publishJavascriptMethods and publishDevelopmentModeJavascriptMethods JSNI
blocks are removed; the published object now delegates to the exported
ApplicationConnection methods and is assembled in publishClient.ts.

Flow.ts registers publishClient before init() (the sole caller of the engine's
init, so every bootstrap path is covered). The GwtApplicationConnectionTest mock
provides a matching stub. The GWT engine still constructs the
ApplicationConnection — its constructor cannot be exported — and then hands the
instance to TypeScript.
The ApplicationConnection constructor calls
window.Vaadin.Flow.internal.publishClient, but Flow.ts registered it only on the
client-side router path. Web component embedding (and any path that starts the
engine without going through Flow.ts) hit "publishClient is not a function",
failing app bootstrap.

Register publishClient at the start of the engine's init() instead, so every
caller of init() gets it right before the engine constructs the
ApplicationConnection. It runs per init(), so it survives any window.Vaadin reset
that happens during bootstrapping.
JsInteropProbe (with its GwtTest and web-test-runner test) proved the GWT->TS
JsInterop export mechanism before any real class was migrated. ApplicationConnection
and ApplicationConfiguration are now exported and exercised end to end (FlowTests,
the it-tests, GwtApplicationConnectionTest), so the probe is redundant. Drop it and
trim internal/gwtExports.ts to the typed contracts the TypeScript code uses.
Move ElementUtil's logic (hasTag, getElementById, getElementByName) to
internal/ElementUtil.ts; the Java class now delegates to it via JSNI. Add a
central registerInternals.ts that publishes the TypeScript implementations the
GWT engine calls into (replacing the inline publishClient registration in the
FlowClient.js wrapper), so future migrations register there. Unit-test the
TypeScript in ElementUtilTests.ts.

First step of the import-direction migration: the GWT engine calls into the
TypeScript implementation, and the old Java logic is removed.
GwtTests do not run the engine's init(), so the TypeScript implementations
the GWT engine delegates to (window.Vaadin.Flow.internal.*, such as the
migrated ElementUtil) were absent. GwtBasicElementBinderTest's ElementUtil
calls therefore failed with "hasTag/getElementById of undefined".

Bundle those implementations with esbuild and transpile them to ES5 (the
HtmlUnit used by GwtTests cannot parse newer syntax or the unicode regex
flag), embed the bundle via a GWT ClientBundle, and eval it in
ClientEngineTestBase.gwtSetUp() so every GwtTest registers the same
implementations production does. GwtApplicationConnectionTest now preserves
and reuses the real publishClient instead of a hand-written stub.
Move the URL helpers redirect, getAbsoluteUrl and isAbsoluteUrl from
WidgetUtil.java to WidgetUtil.ts, registered on
window.Vaadin.Flow.internal.WidgetUtil; the Java methods now delegate to it.
This is the first slice of the WidgetUtil migration; the remaining helpers
follow in later commits. getAbsoluteUrl stays covered by GwtWidgetUtilTest,
and WidgetUtilTests covers isAbsoluteUrl and getAbsoluteUrl in mocha.
…Script

Move the JavaScript object/property helpers (getJsProperty, setJsProperty,
hasOwnJsProperty, hasJsProperty, isUndefined, deleteJsProperty, isTrueish,
getKeys, createJsonObject, createJsonObjectWithoutPrototype, equalsInJS) from
WidgetUtil.java to WidgetUtil.ts; the Java methods now delegate to it. These
helpers are exercised pervasively by the binder GwtTests and are covered
directly by WidgetUtilTests.
Move stringify and the toPrettyJson serializer from WidgetUtil.java to
WidgetUtil.ts; the Java methods delegate to it. toPrettyJson keeps its
GWT.isScript() guard so the legacy hosted-mode fallback is preserved - only
its JSNI implementation moves. Covered by WidgetUtilTests.
Move isLitElement and whenRendered from LitUtils.java to LitUtils.ts; the Java
methods delegate to it. whenRendered still wraps the Runnable in $entry on the
Java side so GWT exception handling is preserved, passing the guarded callback
to the TypeScript implementation. Covered by LitUtilsTests.
Move addReadyCallback from ReactUtils.java to ReactUtils.ts; the Java method
delegates to it, still wrapping the Runnable in $entry on the Java side so GWT
exception handling is preserved. isInitialized stays in Java as it is pure Java
logic (a Supplier null check) with no JavaScript interop. Covered by
ReactUtilsTests.
Move setState, getState, setProperty, loadingStarted, loadingFinished and
loadingFailed from ConnectionIndicator.java to ConnectionIndicator.ts; the Java
methods delegate to it. The connection-state string constants stay in Java as
they are used by Java callers. Covered by the connection-state handler GwtTests
(GwtDefaultConnectionStateHandlerTest, GwtLoadingIndicatorStateHandlerTest).
…Script

Move the three JSNI probes - getBrowserString (user agent), checkForTouchDevice
and isIos - from BrowserInfo.java to BrowserInfo.ts; the Java methods delegate
to it. The browser/OS detection itself stays in the shared BrowserDetails Java
class (used by both server and client), so the rest of BrowserInfo is unchanged.
Covered by BrowserInfoTests.
Move resendRequest from XhrConnection.java to XhrConnection.ts; the Java method
delegates to it. Covered by XhrConnectionTests.

Also raise the typescript-eslint default-project file cap (default 8, now
exceeded by the growing src/test/frontend suite) so linting keeps working as
more *Tests.ts files are added.
…tyDefined to TS

Move the isPropertyDefined Polymer-property check from
ExecuteJavaScriptElementUtils.java to ExecuteJavaScriptElementUtils.ts; the Java
method delegates to it. The StateNode/binding logic stays in Java. Covered by
GwtExecuteJavaScriptElementUtilsTest and ExecuteJavaScriptElementUtilsTests.
…Script

Move recreateNodes, showPopover and getShadowRootElement from
SystemErrorHandler.java to SystemErrorHandler.ts; the Java methods delegate to
it. recreateNodes now snapshots the live element collection before mutating it.
Covered by SystemErrorHandlerTests.
Move createReturnChannelCallback and applyCaptures from ClientJsonCodec.java to
ClientJsonCodec.ts; the Java methods delegate to it. createReturnChannelCallback
still wires the $entry-guarded ServerConnector callback on the Java side;
applyCaptures preserves the caller's this. Covered by GwtClientJsonCodecTest and
ClientJsonCodecTests.
Move supportsHtmlWhenReady, addHtmlImportsReadyHandler, addOnloadHandler,
getStyleSheetLength and runPromiseExpression from ResourceLoader.java to
ResourceLoader.ts; the Java methods delegate, keeping the Runnable/listener
callbacks $entry-guarded on the Java side. Covered by ResourceLoaderTests.
The migrated runPromiseExpression delegation wrapped the promise supplier in
$entry, so an exception thrown while evaluating a dynamic-import expression was
reported both to GWT's uncaught-exception handler and by runPromiseExpression's
own catch (console.error), producing an extra SEVERE log entry
(DynamicDependencyIT.dependecyLoaderThrows_errorLogged expected 2, got 3). The
supplier's exception is expected and handled by the try/catch, so it must not be
$entry-wrapped; onSuccess and onError stay wrapped.
Artur- added 29 commits June 22, 2026 16:53
Move the four Web Storage wrappers (local/session get and set) to a
StorageUtil TS module; the Java methods delegate via JSNI.
Move the navigator.sendBeacon wrapper to a MessageSender TS module; the
Java method delegates via JSNI.
…Script

Move bindPolymerModelProperties and its hookUpPolymerElement helper to a
SimpleElementBindingStrategy TS module. The Java method now builds the
$entry-wrapped, node/tree-capturing callbacks (handlePropertiesChanged,
fireReadyEvent, handleListItemPropertyChange) and delegates the Polymer
DOM wiring (the _propertiesChanged/ready and dom-repeat prototype
patching) to TS. The now-unused native hookUpPolymerElement is removed.
The migrated TypeScript implementations are eval'd in $wnd, so bundle
code resolves Promise from $wnd rather than the GWT module window that
gwtSetUp polyfills. Mirror the same synchronous Promise onto $wnd so
deferred bundle callbacks (the Polymer whenDefined handling in
SimpleElementBindingStrategy) run immediately in tests, as the engine's
JSNI did when it ran in the module window. Fixes the GwtBasicElementBinderTest
and GwtPolymerModelTest deferred-Polymer regressions.
…to TypeScript

Move the default Atmosphere configuration builder to the TS module; the
Java method delegates and passes PushConstants.MESSAGE_DELIMITER down so
the constant stays the single source of truth.
…undary

Mark PolymerUtils model-data, doConnect, createConfig,
ExecuteJavaScriptProcessor context builder, StorageUtil,
MessageSender.sendBeacon and the Polymer model-property binding as done
and CI-green. Document the real-logic leaf phase as complete, the
$wnd-vs-module-window Promise/polyfill rule, the local bundle-rebuild
trap, and that the remaining structural tier needs bottom-up caller
migration rather than leaf delegation.
…ypeScript

Move the element.$server RPC-object helpers (initPromiseHandler,
removeMethod, getMethods, rejectPromises and the node-reading
getPolymerPropertyObject) to a ServerEventObject TS module operating on
the $server object passed from Java. defineMethod stays in Java: its
handler is assigned to the $server object wrapped in $entry, which routes
the handler's exceptions to GWT's uncaught-exception handler and cannot be
reproduced from TypeScript. First step of the structural tier.
…lding to TypeScript

Move the system-error notification construction to the SystemErrorHandler
TS module, reusing the already-migrated showPopover and getShadowRootElement
helpers. The Java method delegates and passes a logError callback so the
Console production-mode gating is preserved. Removes the now-unused
findShadowRoot helper and the Java getShadowRootElement native.
…ypeScript

Move the vaadin.browserLog localStorage flag check to a Console TS module;
the Java method delegates. The browser-console logging, its GWT.isScript()
gating and the uncaught-exception-handler machinery stay in Java.
…eScript

Move the bootstrap configuration readers (getConfigString,
getConfigValueMap, getConfigStringArray, getConfigBoolean, getConfigError,
getVaadinVersion, getAtmosphereVersion) to a JsoConfiguration TS module;
the Java methods delegate, passing the config object. getConfigInteger
stays in Java (boxed java.lang.Integer return) and getAtmosphereJSVersion
stays in Java because its JSNI reference to
AtmospherePushConnection.isAtmosphereLoaded keeps that class reachable for
the GWT deferred-binding of AtmospherePushConnection$Factory in
MessageSender. First step of the bootstrap/config subsystem rewrite.
…getters

CI prettier requires parentheses around the nullish-coalescing in
`info ? info.vaadinVersion ?? null : null`, but the local format hook
strips them. Rewrite as `info?.vaadinVersion ?? null` which both agree on.
Delete the ErrorMessage JavaScriptObject overlay. The session-expired error
flows as the raw native config object: JsoConfiguration.getConfigError and
ApplicationConfiguration's getter/setter now use JavaScriptObject, and
SystemErrorHandler reads its caption/message/url fields via the TS
WidgetUtil.getJsProperty. Removes one GWT JSO overlay class from the
bootstrap/config cluster.
Move startApplicationImmediately, deferStartApplication and registerCallback
to a Bootstrapper TS module; the Java methods delegate, passing the
$entry-wrapped doStartApplication/startApplication callbacks down.
vaadinBootstrapLoaded stays in Java because its $wnd.Vaadin.Flow check is
circular with registerInternals.
…peScript

Move the $wnd.Vaadin.Flow.getApp lookup to the Bootstrapper TS module; the
Java method delegates. This is the last clean leaf of the bootstrap/config
cluster; vaadinBootstrapLoaded stays Java (circular with registerInternals).
…ypeScript

Move the last config reader to TS. GWT represents java.lang.Integer as a JS
number at runtime, so the TS function returns the number (or null) and the
Java native hands it back as the boxed Integer the callers expect. Validated
by the it-tests, which read UI_ID and heartbeatInterval on every app boot.
Move the self-contained profiler leaves (getPerformanceTiming,
getGwtStatsEvents, clearEventsList, hasHighPrecisionTime, round) to a
Profiler TS module; the Java methods delegate. The GwtStatsEvent JSO
accessors, the __gwtStatsEvent logger setup and the RelativeTimeSupplier
getRelativeTime implementations stay in Java for now.
Move ensureLogger and ensureNoLogger (the __gwtStatsEvent collecting-logger
install/teardown) to the Profiler TS module; the Java methods delegate. The
logGwtEvent reporter (GWT/EVT_GROUP refs), the GwtStatsEvent JSO accessors
and the RelativeTimeSupplier getRelativeTime impls stay in Java for now.
Move the __gwtStatsEvent reporter to the Profiler TS module; the Java method
passes the Java-computed values (EVT_GROUP, GWT.getModuleName(),
getRelativeTimeMillis()) down and TS builds the event object. The
GwtStatsEvent JSO accessors and the RelativeTimeSupplier getRelativeTime
impls remain in Java.
…e the Java class

LocationParser had no Java callers, so port its getParameter query-string
parser to a standalone TS module (matching Java's split("=", 2) first-=
semantics via indexOf) with a mocha test, and delete LocationParser.java and
its JUnit test. First Java class fully removed from flow-client (106 -> 105
main Java files).
Move pushArray, spliceArray and clear (the per-mutation bulk array ops, using
spread) to a JsArray TS module; the JsniHelper methods delegate. The
per-element getValueNative/setValueNative stay in Java (hottest path).
…ewrite plan

Record why the import-direction pattern (TS impl + Java native delegation) is
exhausted: it only fits JSNI classes whose bodies are JS logic, all now
migrated. The remaining ~78 pure-Java files form OO connected components
(abstract Computation/ReactiveEventRouter subclassed by node features,
interfaces implemented in Java, Reactive<->Computation mutual recursion) that
cannot migrate incrementally -- the reactive cluster alone is a ~20-30 file
all-or-nothing component. Add a bottom-up staged plan (collections -> reactive
-> node features -> state tree -> binding) for the wholesale core rewrite.
…t -> callback)

Make Computation a concrete class that runs an injected recompute Command,
instead of an abstract doRecompute() that callers override. The one production
subclass (Reactive.runWhenDependenciesChange) now uses the callback
constructor. A protected no-arg constructor still supports overriding
doRecompute (the CountingComputation test helper). First decoupling step toward
migrating the reactive component to TypeScript: removes the abstract-subclass
blocker from production so Computation can later move behind a thin shell.
Validated by ComputationTest and ReactiveTest (JVM).
…(composition)

Make ReactiveEventRouter a concrete class taking its wrap/dispatch behavior as
constructor callbacks (nested ListenerWrapper/EventDispatcher functional
interfaces) instead of abstract methods. MapProperty, NodeMap and NodeList now
instantiate it with lambdas instead of anonymous subclasses; the
TestReactiveEventRouter helper likewise. Second decoupling step toward a TS
reactive layer: removes the abstract-generic-subclass blocker so the router can
later move to TS with node features as plain consumers. Validated by
MapPropertyTest/NodeListTest/NodeMapTest/ComputationTest/ReactiveTest (JVM).
…-cutover)

Port the reactive core (Reactive, Computation, ReactiveEventRouter and the
event/listener/value types) to a TypeScript module, mirroring the Java
semantics, with mocha tests porting ReactiveTest + ComputationTest (17 cases).

This intentionally coexists with the Java reactive package for now: per the
agreed plan for the core rewrite, the TS implementation is built and validated
in isolation first, then the consumers are wired to it and the Java package
deleted in a cutover. No Java is touched, so the build stays green.
…utover)

Port MapProperty and its change event/listener to TypeScript on top of the TS
reactive core, with mocha tests porting the MapPropertyTest behaviour (value
get/set, change events, reactive read tracking, removeValue, getValueOrDefault
overloads, syncToServer active/inactive). The state-tree classes it touches
(NodeMap/StateNode/StateTree) are not ported yet, so the slice it needs is
declared as TS contracts the future node features will satisfy at cutover.

Continues the build-alongside core rewrite; no Java touched, build stays green.
…-alongside)

Port NodeFeature (base), NodeMap and NodeList plus their property-add and splice
events/listeners to TypeScript on top of the TS reactive core and MapProperty,
with mocha tests porting NodeMapTest (9) and NodeListTest (5). The StateNode
slice the features need is a TS contract the future TS StateNode will satisfy at
cutover.

Continues the build-alongside state-tree chunk; no Java touched, build green.
…over)

Port StateNode and its node-unregister event/listener to TypeScript on top of
the TS node features, with mocha tests porting StateNodeTest (4). The Java
Class<?>-keyed nodeData map becomes a map keyed by JS constructor function
(object.constructor / getNodeData(clazz)). StateTree is Registry-coupled and
not ported yet, so the slice StateNode and the node features need is a TS
contract the future TS StateTree will satisfy at cutover.

Continues the build-alongside state-tree chunk; no Java touched, build green.
Document the layers landed so far (reactive core, MapProperty, node features,
StateNode) and the contract/cutover approach in MIGRATION_STRATEGY.md.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant