Skip to content

[bindings] Support asynchronous initialization for configured npm modules #7

Description

@GG-O-BP

Context

External npm components may require asynchronous module initialization before they are safe to render or before non-React APIs on the same module can be used. mendix-ironcalc-spreadui currently owns this lifecycle in project-local JavaScript:

  • a module-level cached initialization Promise implements one-flight behavior;
  • import("@ironcalc/workbook") dynamically loads the module;
  • module.init() performs async WebAssembly initialization;
  • a module-level ironCalcModule stores the initialized namespace;
  • loaders/importers await the cached Promise and translate failures to callbacks.

Glendix already owns configured npm bindings, but Promise orchestration should use the Gleam ecosystem rather than becoming another handwritten JavaScript FFI.

Ecosystem-first FFI assessment (2026-09-07)

Current JavaScript responsibility Candidate Gleam package/API Assessment
Promise creation/chaining and one-flight result sharing gleam/javascript/promise (resolve, await, map, tap, rescue) Use directly. Glendix already depends on gleam_javascript; do not duplicate Promise operations. Coordinate with #12.
Consume an initializer from a React component with Suspense redraw.use_promise Optional React-specific consumer API. It is not the default for the existing Lustre TEA flow, which should dispatch completion through an effect.
Store explicit initialization state pure Gleam Uninitialized / Initializing(Promise) / Ready / Failed state owned by the runtime/model Preferred over hidden JavaScript globals where lifecycle ownership can be explicit.
Dynamic import plinth/browser/window.import_ Package-backed but returns Dynamic and changes the current static-import/Rollup contract. It may be useful for an explicitly dynamic mode, but is not the default solution here.
Decode dynamic module data/errors gleam/dynamic/decode Suitable for data/error decoding, but it does not by itself provide a typed call to an arbitrary configured function export.
Statically import and call a config-selected npm export generated Glendix binding or generated direct @external declaration Residual boundary. Package APIs cannot know a package/export name supplied later in gleam.toml. Keep this generated and minimal; do not handwrite per-widget .mjs.

Revised proposal

Design the feature as a package-backed lifecycle around the smallest possible generated binding boundary:

  1. Extend binding configuration to identify an initialization export and whether initialization is one-flight/retryable.
  2. Preserve static imports for deterministic Rollup/WASM behavior.
  3. Have the generated binding expose only the typed initializer/module handle needed by Gleam; investigate generated direct @external(javascript, package, export) declarations before adding runtime reflection.
  4. Implement Promise composition, result mapping, and lifecycle state in Gleam using gleam/javascript/promise.
  5. Use Lustre effects for TEA completion; expose redraw.use_promise only as an optional React/Suspense integration.
  6. Do not add handwritten JavaScript for Promise caching/chaining. If generated static import/call code remains necessary, document it as retained configuration-driven FFI under [ffi] Document and contract-test retained Glendix-specific FFI boundaries #21.

Compatibility and public API impact

  • Existing component-only bindings must continue to generate static imports and require no initialization config.
  • Concurrent consumers must share one in-flight initialization attempt.
  • Specify retry semantics: either cache failures until explicitly reset, or clear the failed attempt for a later retry; do not leave this implicit.
  • Preserve module lookup, export lookup, initialization rejection, and WASM-load failures as distinct typed errors where possible.
  • Avoid plinth/browser/window.import_ in the default path because it changes bundle determinism and returns an untyped module namespace.

Non-goals

  • Reimplementing JavaScript Promise combinators in Glendix.
  • Using runtime reflection when generated static declarations can preserve types and bundler behavior.
  • Hard-coding IronCalc package names or initialization semantics.
  • Treating React Suspense as mandatory for Lustre consumers.

Acceptance criteria

  • An implementation design records the exact residual generated FFI, and why no package can replace the config-selected static import/export boundary.
  • Promise orchestration uses gleam/javascript/promise; no new handwritten Promise adapter is added.
  • Tests use a synthetic configured module and cover success, failure, concurrent one-flight calls, the selected retry policy, and reuse by both rendering and non-React API consumers.
  • Existing component-only bindings continue to work without initialization configuration.
  • Generated build output still uses deterministic static imports and resolves WebAssembly assets correctly.
  • Dynamic-import mode, if offered, is explicit and tested separately from the default static mode.
  • Documentation explains the package-backed and retained/generated parts of the boundary.

Verification

  • Local implementation loop: ./scripts/verify.sh inner glendix
  • Binding/public boundary change: ./scripts/verify.sh shared glendix
  • Before a release or compatibility claim: ./scripts/verify.sh final

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestffiForeign-function interface boundaries and interoprefactorInternal restructuring without an intended feature changespikeTime-boxed investigation needed before implementation

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions