diff --git a/README.ja.md b/README.ja.md index 1590758..2695718 100644 --- a/README.ja.md +++ b/README.ja.md @@ -166,6 +166,38 @@ pub fn pie_chart( npm バインディングは Glendix 単体で動作します。 +非同期の準備が必要な module は拡張 table 形式で設定します。 + +```toml +[tools.glendix.bindings."@ironcalc/workbook"] +exports = ["Workbook"] +initializer = "init" +retry = "on-failure" +``` + +`initializer` は引数なしで呼び出され、Promise を返す export 名です。`retry` は +既定値の `"never"`(明示的に reset するまで失敗を cache)または +`"on-failure"`(失敗結果を state に記録した後、次の呼び出しで再試行)です。 +非 React API だけを使う module は `exports` を省略できます。従来の文字列・配列 +形式は初期化不要の module としてそのまま動作します。 + +`binding.initialization` で caller-owned state を作り、`binding.initialize` が返す +`ModuleInitialization` を application model に保存します。`Initializing` 中の +同時呼び出しは同じ Promise を共有します。完了結果を +`binding.settle_initialization` に渡した後、`binding.initialized_module` から同じ +ready module を rendering と非 React API の両方で利用できます。Lustre は +`binding.initialization_effect`、React Suspense は同じ Promise に +`binding.use_initialization` を利用できます。`binding.reset_initialization` は +失敗済みまたは ready の状態を明示的に reset します。 + +TOML の package/export 名は Glendix 自体の compile 後に読み込まれるため、installer +は生成 FFI 境界を 1 つ保持します。この境界は決定的な static named import、metadata +参照、initializer の直接呼び出しだけを行います。Promise の生成、one-flight 共有、 +結果変換、retry state、完了 dispatch は `gleam/javascript/promise` を使う Gleam +コードが担当します。dynamic `import()` や生成 Promise cache は使わないため、 +Rollup と WebAssembly asset 検出の決定性も維持されます。 + + ## ブラウザ環境とオブジェクト prop `glendix/js/environment` は `window.matchMedia` を型付き境界の内側に隠し、ウィ diff --git a/README.ko.md b/README.ko.md index adbd9f1..8f61181 100644 --- a/README.ko.md +++ b/README.ko.md @@ -161,6 +161,38 @@ pub fn pie_chart( npm 바인딩은 Glendix만으로 동작한다. `binding.element_`와 `binding.void_element`도 제공한다. +비동기 준비가 필요한 모듈은 확장 테이블 형식으로 설정한다. + +```toml +[tools.glendix.bindings."@ironcalc/workbook"] +exports = ["Workbook"] +initializer = "init" +retry = "on-failure" +``` + +`initializer`는 인자 없이 호출하며 Promise를 반환하는 export 이름이다. `retry`는 +기본값인 `"never"`(명시적으로 reset할 때까지 실패를 캐시) 또는 +`"on-failure"`(실패 결과를 state에 반영한 뒤 다음 호출에서 재시도)다. React +컴포넌트가 아닌 API만 쓰는 모듈은 `exports`를 생략할 수 있다. 기존 문자열과 +문자열 배열 형식은 초기화가 필요 없는 모듈로 그대로 동작한다. + +`binding.initialization`으로 caller-owned state를 만들고 `binding.initialize`가 +반환한 `ModuleInitialization`을 애플리케이션 model에 저장한다. `Initializing` +상태의 동시 호출은 같은 Promise를 공유한다. 완료 결과를 +`binding.settle_initialization`에 전달한 뒤 `binding.initialized_module`로 같은 +준비 완료 모듈을 렌더링과 non-React API에서 함께 사용한다. Lustre는 +`binding.initialization_effect`, React Suspense는 같은 Promise에 +`binding.use_initialization`을 사용할 수 있다. `binding.reset_initialization`은 +실패 또는 준비 완료 상태를 명시적으로 초기화한다. + +TOML의 package/export 이름은 Glendix가 이미 compile된 뒤 읽히므로 installer는 +생성된 FFI 경계를 하나 유지한다. 이 경계는 결정적인 정적 named import, metadata +조회, initializer 직접 호출만 수행한다. Promise 생성·one-flight 공유·결과 변환·retry +state·완료 dispatch는 `gleam/javascript/promise`를 사용하는 Gleam 코드가 담당한다. +동적 `import()`나 생성된 Promise cache가 없으므로 Rollup과 WebAssembly asset 탐색도 +결정적으로 유지된다. + + ## 브라우저 환경과 객체 prop `glendix/js/environment`는 `window.matchMedia`를 타입 경계 뒤에 숨겨, 위젯이 diff --git a/README.md b/README.md index f4c83ff..de6a278 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,57 @@ pub fn pie_chart( `binding.element_` creates an element with children only, and `binding.void_element` creates one without children. +Modules that require asynchronous setup use the extended table form: + +```toml +[tools.glendix.bindings."@ironcalc/workbook"] +exports = ["Workbook"] +initializer = "init" +retry = "on-failure" +``` + +`initializer` names a zero-argument export that must return a Promise. `retry` +is either `"never"` (the default, which caches a failure until explicit reset) +or `"on-failure"` (the next initialization call may start a new attempt after +the failed result has been recorded). `exports` may be omitted for modules used +only through non-React APIs. Legacy string and array values require no +initialization and remain ready immediately. + +```gleam +import gleam/javascript/promise +import glendix/binding + +pub fn load( + module module: binding.JsModule, +) -> #( + binding.ModuleInitialization, + promise.Promise(Result(Nil, binding.InitializationError)), +) { + module + |> binding.initialization + |> binding.initialize +} +``` + +Store the returned `ModuleInitialization` in the application model. Concurrent +calls made with its `Initializing` value return the same Promise. When that +Promise completes, pass its result to `binding.settle_initialization`; then +`binding.initialized_module` makes the same ready module available to rendering +and non-React consumers. `binding.initialization_effect` dispatches the result +through Lustre, while `binding.use_initialization` consumes the same attempt in +a React Suspense boundary. `binding.reset_initialization` explicitly clears a +settled failure (or reinitializes a ready configured module). + +The installer retains one generated FFI boundary because the package and export +names are read from `gleam.toml` after Glendix itself has compiled; Gleam +`@external` package/export literals must exist before compilation. That boundary +contains deterministic static named imports, metadata lookup, and the direct +initializer call only. Promise creation, one-flight sharing, result mapping, +retry state, and completion dispatch are implemented in Gleam with +`gleam/javascript/promise`. No dynamic `import()` or generated Promise cache is +used, so Rollup can still discover module WebAssembly assets. + + ## Browser environment and object props `glendix/js/environment` reads the browser color-scheme preference behind a diff --git a/glendix_guide.ja.md b/glendix_guide.ja.md index b16d54f..dd8513c 100644 --- a/glendix_guide.ja.md +++ b/glendix_guide.ja.md @@ -84,6 +84,29 @@ pub fn pie_chart( npm バインディングは Glendix 単体で動作します。 +非同期準備が必要な module は拡張形式で設定します。 + +```toml +[tools.glendix.bindings."@ironcalc/workbook"] +exports = ["Workbook"] +initializer = "init" +retry = "on-failure" +``` + +initializer は引数なしで呼び出され、Promise を返す必要があります。`retry` の既定値 +`"never"` は reset まで失敗を cache し、`"on-failure"` は失敗結果の記録後に次の +試行を許可します。`binding.initialization` と `binding.initialize` が返す +`ModuleInitialization` を model に保存すると、同時呼び出しは同じ Promise を共有 +します。完了は `binding.settle_initialization` で記録し、 +`binding.initialized_module` を rendering と非 React API で共有します。Lustre は +`binding.initialization_effect`、React Suspense は `binding.use_initialization` を +利用できます。 + +生成コードは TOML で選択した export の static import と直接呼び出しだけを担当し、 +Promise と retry の orchestration は `gleam/javascript/promise` ベースの Gleam +コードにあります。dynamic `import()` や生成 Promise cache は使いません。 + + ## ブラウザ環境とオブジェクト prop `glendix/js/environment` を使うと、`window.matchMedia` を公開せずに diff --git a/glendix_guide.ko.md b/glendix_guide.ko.md index cce0535..e6fa0d4 100644 --- a/glendix_guide.ko.md +++ b/glendix_guide.ko.md @@ -83,6 +83,29 @@ pub fn pie_chart( npm 바인딩은 Glendix만으로 동작한다. `binding.element_`와 `binding.void_element`도 제공한다. +비동기 준비가 필요한 모듈은 확장 형식으로 설정한다. + +```toml +[tools.glendix.bindings."@ironcalc/workbook"] +exports = ["Workbook"] +initializer = "init" +retry = "on-failure" +``` + +initializer는 인자 없이 호출하며 Promise를 반환해야 한다. `retry` 기본값 +`"never"`는 reset 전까지 실패를 캐시하고, `"on-failure"`는 실패 결과를 반영한 뒤 +다음 시도를 허용한다. `binding.initialization`과 `binding.initialize`로 만든 +`ModuleInitialization`을 model에 저장하면 동시 호출이 같은 Promise를 공유한다. +완료 결과는 `binding.settle_initialization`으로 반영하고, +`binding.initialized_module`을 렌더링과 non-React API가 함께 사용한다. Lustre는 +`binding.initialization_effect`, React Suspense는 `binding.use_initialization`을 +사용한다. + +생성 코드는 TOML로 선택한 export의 정적 import와 직접 호출만 담당한다. Promise와 +retry orchestration은 `gleam/javascript/promise` 기반 Gleam 코드에 있으며 동적 +`import()`나 생성된 Promise cache를 사용하지 않는다. + + ## 브라우저 환경과 객체 prop `glendix/js/environment`를 사용하면 `window.matchMedia`를 노출하지 않고 diff --git a/glendix_guide.md b/glendix_guide.md index da4ccba..fafbceb 100644 --- a/glendix_guide.md +++ b/glendix_guide.md @@ -136,6 +136,32 @@ pub fn pie_chart( `binding.element_` creates an element with children only, and `binding.void_element` creates one without children. +For a module that must finish asynchronous setup before use, configure an +extended binding: + +```toml +[tools.glendix.bindings."@ironcalc/workbook"] +exports = ["Workbook"] +initializer = "init" +retry = "on-failure" +``` + +The initializer must be a zero-argument Promise-returning export. `retry` is +`"never"` by default (cache failure until `binding.reset_initialization`) or +`"on-failure"` (allow a later attempt after settlement). Create state with +`binding.initialization`, call `binding.initialize`, and store the returned +`ModuleInitialization`. Concurrent calls share its Promise. Record completion +with `binding.settle_initialization`, then use `binding.initialized_module` for +both rendering and non-React consumers. Lustre can use +`binding.initialization_effect`; React Suspense can use +`binding.use_initialization` with that same Promise. + +Glendix keeps only a generated static-import/direct-call FFI for names selected +from TOML after package compilation. All Promise and retry orchestration uses +`gleam/javascript/promise`; the generated code has no dynamic import or Promise +cache, preserving deterministic Rollup and WebAssembly asset discovery. + + ## Browser environment and object props Use `glendix/js/environment` to read `prefers-color-scheme` without exposing diff --git a/src/glendix/binding.gleam b/src/glendix/binding.gleam index e04f7c5..3e6af36 100644 --- a/src/glendix/binding.gleam +++ b/src/glendix/binding.gleam @@ -16,7 +16,11 @@ //// } //// ``` +import gleam/javascript/promise +import gleam/option import gleam/result +import glendix/js/promise as glendix_promise +import lustre/effect import redraw import redraw/dom/attribute @@ -34,6 +38,45 @@ pub type BindingError { ExportWasNotFound(name: String, reason: String) } +/// Describes a configured module initialization failure. +pub type InitializationError { + /// The configured initializer could not be invoked as a Promise operation. + InitializationCouldNotStart( + module_name: String, + export_name: String, + reason: String, + ) + /// The initializer Promise rejected before the module became ready. + InitializationRejected( + module_name: String, + export_name: String, + reason: String, + ) +} + +/// Reports the current caller-owned module initialization phase. +pub type InitializationStatus { + /// The configured initializer has not started. + Uninitialized + /// One initializer Promise is currently shared by every consumer. + Initializing + /// The module is safe for rendering and non-React API use. + Ready + /// The most recent initialization attempt failed. + Failed(error: InitializationError) +} + +/// Caller-owned one-flight state for one configured JavaScript module. +pub opaque type ModuleInitialization { + ModuleInitialization( + module: JsModule, + module_name: String, + export_name: String, + failure_policy: InitializationFailurePolicy, + phase: InitializationPhase, + ) +} + /// Returns a JavaScript module handle by name. pub fn module(name name: String) -> Result(JsModule, BindingError) { module_raw(name) @@ -53,6 +96,142 @@ pub fn resolve( }) } +/// Creates caller-owned initialization state for a configured module. +/// +/// Modules without an initializer begin in `Ready`; configured initializers +/// begin in `Uninitialized`. +pub fn initialization(module module: JsModule) -> ModuleInitialization { + let module_name = module_name_raw(module) + let export_name = initialization_export_name_raw(module) + let failure_policy = case initialization_retry_policy_raw(module) { + "on-failure" -> RetryFailure + _ -> CacheFailure + } + let phase = case export_name { + "" -> InitializationReady + _ -> InitializationUninitialized + } + ModuleInitialization( + module: module, + module_name: module_name, + export_name: export_name, + failure_policy: failure_policy, + phase: phase, + ) +} + +/// Reports the current phase without exposing the shared Promise. +pub fn initialization_status( + initialization initialization: ModuleInitialization, +) -> InitializationStatus { + let ModuleInitialization(phase: phase, ..) = initialization + case phase { + InitializationUninitialized -> Uninitialized + InitializationInitializing(_) -> Initializing + InitializationReady -> Ready + InitializationFailed(error) -> Failed(error) + } +} + +/// Starts or reuses the module's one-flight initialization attempt. +/// +/// The returned lifecycle must replace the caller's previous value. A caller +/// records the Promise result with `settle_initialization` before starting a +/// later retry. +pub fn initialize( + initialization initialization: ModuleInitialization, +) -> #(ModuleInitialization, promise.Promise(Result(Nil, InitializationError))) { + let ModuleInitialization(failure_policy: failure_policy, phase: phase, ..) = + initialization + case phase { + InitializationUninitialized -> start_initialization(initialization) + InitializationInitializing(attempt) -> #(initialization, attempt) + InitializationReady -> #(initialization, promise.resolve(Ok(Nil))) + InitializationFailed(error) -> + case failure_policy { + CacheFailure -> #(initialization, promise.resolve(Error(error))) + RetryFailure -> start_initialization(initialization) + } + } +} + +/// Records one attempt's completion in caller-owned lifecycle state. +/// +/// Calls outside `Initializing` are ignored so duplicate or stale completion +/// messages cannot replace an already-settled state. +pub fn settle_initialization( + initialization initialization: ModuleInitialization, + with outcome: Result(Nil, InitializationError), +) -> ModuleInitialization { + let ModuleInitialization(phase: phase, ..) = initialization + case phase { + InitializationInitializing(_) -> + case outcome { + Ok(Nil) -> with_phase(initialization, InitializationReady) + Error(error) -> with_phase(initialization, InitializationFailed(error)) + } + InitializationUninitialized + | InitializationReady + | InitializationFailed(_) -> initialization + } +} + +/// Resets a settled configured initializer for an explicit later attempt. +/// +/// An in-flight attempt is never reset because its eventual completion would +/// otherwise race a newer attempt. Modules without an initializer remain ready. +pub fn reset_initialization( + initialization initialization: ModuleInitialization, +) -> ModuleInitialization { + let ModuleInitialization(export_name: export_name, phase: phase, ..) = + initialization + case export_name, phase { + "", _ -> initialization + _, InitializationInitializing(_) -> initialization + _, InitializationUninitialized -> initialization + _, InitializationReady | _, InitializationFailed(_) -> + with_phase(initialization, InitializationUninitialized) + } +} + +/// Returns the module only after it is ready for every configured consumer. +pub fn initialized_module( + initialization initialization: ModuleInitialization, +) -> option.Option(JsModule) { + let ModuleInitialization(module: module, phase: phase, ..) = initialization + case phase { + InitializationReady -> option.Some(module) + InitializationUninitialized + | InitializationInitializing(_) + | InitializationFailed(_) -> option.None + } +} + +/// Starts or reuses initialization and dispatches its result as a Lustre effect. +pub fn initialization_effect( + initialization initialization: ModuleInitialization, + to_message to_message: fn(Result(Nil, InitializationError)) -> message, +) -> #(ModuleInitialization, effect.Effect(message)) { + let #(next, attempt) = initialize(initialization) + let initialization_effect = + effect.from(fn(dispatch) { + glendix_promise.await_(attempt, then: fn(outcome) { + dispatch(to_message(outcome)) + }) + }) + #(next, initialization_effect) +} + +/// Reads an initialization attempt from a React Suspense boundary. +/// +/// Store the lifecycle returned by `initialize` outside the render call so +/// rerenders reuse the same attempt. +pub fn use_initialization( + attempt attempt: promise.Promise(Result(Nil, InitializationError)), +) -> Result(Nil, InitializationError) { + redraw.use_promise(attempt) +} + /// Creates an element from an external component, attributes, and children. pub fn element( component component: JsComponent, @@ -80,6 +259,81 @@ pub fn void_element( type RawBindingError +type InitializationFailurePolicy { + CacheFailure + RetryFailure +} + +type InitializationPhase { + InitializationUninitialized + InitializationInitializing(promise.Promise(Result(Nil, InitializationError))) + InitializationReady + InitializationFailed(InitializationError) +} + +fn start_initialization( + initialization: ModuleInitialization, +) -> #(ModuleInitialization, promise.Promise(Result(Nil, InitializationError))) { + let ModuleInitialization( + module: module, + module_name: module_name, + export_name: export_name, + .., + ) = initialization + case initialize_module_raw(module) { + Error(raw_error) -> { + let error = + InitializationCouldNotStart( + module_name: module_name, + export_name: export_name, + reason: raw_binding_error_message(raw_error), + ) + #( + with_phase(initialization, InitializationFailed(error)), + promise.resolve(Error(error)), + ) + } + Ok(initializer_promise) -> { + let attempt = + initializer_promise + |> promise.map(fn(_) { Ok(Nil) }) + |> glendix_promise.catch_(with: fn(rejection) { + promise.resolve( + Error(InitializationRejected( + module_name: module_name, + export_name: export_name, + reason: promise_rejection_message_raw(rejection), + )), + ) + }) + #( + with_phase(initialization, InitializationInitializing(attempt)), + attempt, + ) + } + } +} + +fn with_phase( + initialization: ModuleInitialization, + phase: InitializationPhase, +) -> ModuleInitialization { + let ModuleInitialization( + module: module, + module_name: module_name, + export_name: export_name, + failure_policy: failure_policy, + .., + ) = initialization + ModuleInitialization( + module: module, + module_name: module_name, + export_name: export_name, + failure_policy: failure_policy, + phase: phase, + ) +} + // -- FFI -- @external(javascript, "./binding_ffi.mjs", "get_module") fn module_raw(name name: String) -> Result(JsModule, RawBindingError) @@ -90,6 +344,20 @@ fn resolve_raw( name name: String, ) -> Result(JsComponent, RawBindingError) +@external(javascript, "./binding_ffi.mjs", "module_name") +fn module_name_raw(module: JsModule) -> String + +@external(javascript, "./binding_ffi.mjs", "initialization_export_name") +fn initialization_export_name_raw(module: JsModule) -> String + +@external(javascript, "./binding_ffi.mjs", "initialization_retry_policy") +fn initialization_retry_policy_raw(module: JsModule) -> String + +@external(javascript, "./binding_ffi.mjs", "initialize_module") +fn initialize_module_raw( + module: JsModule, +) -> Result(promise.Promise(Nil), RawBindingError) + @external(javascript, "./binding_ffi.mjs", "component_element") fn element_raw( component: JsComponent, @@ -111,3 +379,8 @@ fn void_element_raw( @external(javascript, "./binding_ffi.mjs", "binding_error_message") fn raw_binding_error_message(error: RawBindingError) -> String + +@external(javascript, "./binding_ffi.mjs", "binding_error_message") +fn promise_rejection_message_raw( + rejection: glendix_promise.PromiseRejection, +) -> String diff --git a/src/glendix/binding_ffi.mjs b/src/glendix/binding_ffi.mjs index aa47016..13db9dc 100644 --- a/src/glendix/binding_ffi.mjs +++ b/src/glendix/binding_ffi.mjs @@ -6,12 +6,46 @@ export function get_module(name) { `바인딩이 생성되지 않았습니다. 'gleam run -m glendix/install'을 실행하세요. (요청 모듈: ${name})`, ); } -export function resolve(_mod, name) { +export function resolve(mod, name) { + const value = mod?.exports?.[name]; + if (value !== undefined) return new Ok(value); return new GleamError( `바인딩이 생성되지 않았습니다. 'gleam run -m glendix/install'을 실행하세요. (요청 컴포넌트: ${name})`, ); } +export function module_name(mod) { + return mod.name; +} + +export function initialization_export_name(mod) { + return mod.initialization?.exportName ?? ""; +} + +export function initialization_retry_policy(mod) { + return mod.initialization?.retry ?? "never"; +} + +export function initialize_module(mod) { + const initialization = mod.initialization; + if (!initialization) { + return new GleamError( + `Module does not require asynchronous initialization: ${mod.name}`, + ); + } + try { + const promise = initialization.run(); + if (!promise || typeof promise.then !== "function") { + return new GleamError( + `Initializer must return a Promise: ${initialization.exportName}`, + ); + } + return new Ok(promise); + } catch (error) { + return new GleamError(error); + } +} + function toProps(attributes) { const props = {}; const classNames = []; diff --git a/src/glendix/cmd.gleam b/src/glendix/cmd.gleam index 7c93397..a9ae71d 100644 --- a/src/glendix/cmd.gleam +++ b/src/glendix/cmd.gleam @@ -2,6 +2,7 @@ //// import gleam/io +import gleam/list import gleam/option import gleam/result import glendix/configuration @@ -142,6 +143,14 @@ pub fn generate_bindings() -> Result(Nil, CommandError) { |> map_raw_error("generate JavaScript bindings") } +/// Renders generated binding source for focused generator contract tests. +@internal +pub fn render_binding_source( + bindings bindings: List(#(String, List(String), String, String)), +) -> String { + render_binding_source_raw(bindings) +} + /// Prints a command error at a command-line boundary. @internal pub fn report(result result: Result(Nil, CommandError)) -> Nil { @@ -264,11 +273,37 @@ fn read_compatibility( fn read_bindings( project_configuration: configuration.Configuration, -) -> Result(List(#(String, List(String))), CommandError) { - configuration.bindings(project_configuration) +) -> Result(List(#(String, List(String), String, String)), CommandError) { + configuration.binding_configurations(project_configuration) + |> result.map(fn(bindings) { list.map(bindings, binding_input) }) |> map_configuration_error("read binding configuration") } +fn binding_input( + binding: configuration.BindingConfiguration, +) -> #(String, List(String), String, String) { + let configuration.BindingConfiguration(module_name, exports, initialization) = + binding + case initialization { + configuration.NoInitialization -> #(module_name, exports, "", "never") + configuration.Initialize(export_name, failure_policy) -> #( + module_name, + exports, + export_name, + failure_policy_name(failure_policy), + ) + } +} + +fn failure_policy_name( + failure_policy: configuration.InitializationFailurePolicy, +) -> String { + case failure_policy { + configuration.CacheFailure -> "never" + configuration.RetryFailure -> "on-failure" + } +} + fn map_raw_error( raw_result: Result(value, RawCommandError), operation: String, @@ -305,20 +340,25 @@ fn file_exists(path: String) -> Bool @external(javascript, "./cmd_ffi.mjs", "run_with_bridge") fn run_with_bridge( command: String, - bindings: List(#(String, List(String))), + bindings: List(#(String, List(String), String, String)), ) -> Result(Nil, RawCommandError) @external(javascript, "./cmd_ffi.mjs", "run_dev_with_bridge") fn run_dev_with_bridge( build_command: String, - bindings: List(#(String, List(String))), + bindings: List(#(String, List(String), String, String)), ) -> Result(Nil, RawCommandError) @external(javascript, "./cmd_ffi.mjs", "generate_bindings") fn generate_bindings_raw( - bindings: List(#(String, List(String))), + bindings: List(#(String, List(String), String, String)), ) -> Result(Nil, RawCommandError) +@external(javascript, "./cmd_ffi.mjs", "render_binding_source") +fn render_binding_source_raw( + bindings: List(#(String, List(String), String, String)), +) -> String + @external(javascript, "./cmd_ffi.mjs", "run_experimental_native") fn run_experimental_native_raw( package_manager: String, @@ -329,14 +369,14 @@ fn run_experimental_native_raw( fn run_experimental_native_with_bridge_raw( package_manager: String, args: String, - bindings: List(#(String, List(String))), + bindings: List(#(String, List(String), String, String)), ) -> Result(Nil, RawCommandError) @external(javascript, "./cmd_ffi.mjs", "run_experimental_native_dev_with_bridge") fn run_experimental_native_dev_with_bridge_raw( package_manager: String, args: String, - bindings: List(#(String, List(String))), + bindings: List(#(String, List(String), String, String)), ) -> Result(Nil, RawCommandError) @external(javascript, "./cmd_ffi.mjs", "command_error_message") diff --git a/src/glendix/cmd_ffi.mjs b/src/glendix/cmd_ffi.mjs index f67114b..42d85eb 100644 --- a/src/glendix/cmd_ffi.mjs +++ b/src/glendix/cmd_ffi.mjs @@ -90,32 +90,63 @@ export function file_exists(path) { return existsSync(path); } function configuredBindings(bindings) { - return bindings.toArray().map(([moduleName, components]) => [ - moduleName, - components.toArray(), - ]); + return bindings.toArray().map( + ([moduleName, exports, initializer, retry]) => ({ + moduleName, + exports: exports.toArray(), + initializer: initializer || null, + retry, + }), + ); } -function generateBindingsOrThrow(bindings) { - const configuredEntries = configuredBindings(bindings); - if (configuredEntries.length === 0) { - return; - } - const config = {}; - for (const [moduleName, components] of configuredEntries) { - config[moduleName] = { components }; - } +function uniqueNames(names) { + return [...new Set(names)]; +} + +function importedName(exportName) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(exportName) + ? exportName + : JSON.stringify(exportName); +} + +function renderBindingsSource(configuredEntries) { + const modules = configuredEntries.filter( + entry => entry.exports.length > 0 || entry.initializer, + ); + if (modules.length === 0) return null; + const imports = []; const entries = []; - for (const [moduleName, entry] of Object.entries(config)) { - const components = entry.components || []; - if (components.length === 0) continue; - imports.push(`import { ${components.join(", ")} } from "${moduleName}";`); - entries.push(` "${moduleName}": { ${components.join(", ")} }`); - } - if (imports.length === 0) return; - const content = + modules.forEach((entry, moduleIndex) => { + const names = uniqueNames([ + ...entry.exports, + ...(entry.initializer ? [entry.initializer] : []), + ]); + const aliases = new Map( + names.map((name, exportIndex) => [ + name, + `__glendix_module_${moduleIndex}_export_${exportIndex}`, + ]), + ); + imports.push( + `import { ${names.map(name => `${importedName(name)} as ${aliases.get(name)}`).join(", ")} } from ${JSON.stringify(entry.moduleName)};`, + ); + + const exportedValues = uniqueNames(entry.exports).map( + name => `${JSON.stringify(name)}: ${aliases.get(name)}`, + ); + const initialization = entry.initializer + ? `{ exportName: ${JSON.stringify(entry.initializer)}, retry: ${JSON.stringify(entry.retry)}, run: () => ${aliases.get(entry.initializer)}() }` + : "null"; + entries.push( + ` ${JSON.stringify(entry.moduleName)}: { name: ${JSON.stringify(entry.moduleName)}, exports: { ${exportedValues.join(", ")} }, initialization: ${initialization} }`, + ); + }); + + return ( `// Generated by glendix/install. Do not edit manually.\n` + + `// Static imports keep configured modules and their WebAssembly assets visible to Rollup.\n` + `import { Ok, Error as GleamError } from "../gleam.mjs";\n` + `import { createElement } from "react";\n` + imports.join("\n") + @@ -123,13 +154,35 @@ function generateBindingsOrThrow(bindings) { `const _modules = {\n${entries.join(",\n")}\n};\n\n` + `export function get_module(name) {\n` + ` const mod = _modules[name];\n` + - ` if (!mod) return new GleamError("바인딩에 등록되지 않은 모듈: " + name + ". gleam.toml [tools.glendix.bindings]를 확인하세요.");\n` + + ` if (!mod) return new GleamError("No generated binding exists for module: " + name + ". Check [tools.glendix.bindings] in gleam.toml.");\n` + ` return new Ok(mod);\n` + `}\n\n` + `export function resolve(mod, name) {\n` + - ` const c = mod[name];\n` + - ` if (c === undefined) return new GleamError("모듈에 없는 컴포넌트: " + name);\n` + - ` return new Ok(c);\n` + + ` const value = mod.exports[name];\n` + + ` if (value === undefined) return new GleamError("Module export is not configured: " + name);\n` + + ` return new Ok(value);\n` + + `}\n\n` + + `export function module_name(mod) {\n` + + ` return mod.name;\n` + + `}\n\n` + + `export function initialization_export_name(mod) {\n` + + ` return mod.initialization?.exportName ?? "";\n` + + `}\n\n` + + `export function initialization_retry_policy(mod) {\n` + + ` return mod.initialization?.retry ?? "never";\n` + + `}\n\n` + + `export function initialize_module(mod) {\n` + + ` const initialization = mod.initialization;\n` + + ` if (!initialization) return new GleamError("Module does not require asynchronous initialization: " + mod.name);\n` + + ` try {\n` + + ` const promise = initialization.run();\n` + + ` if (!promise || typeof promise.then !== "function") {\n` + + ` return new GleamError("Initializer must return a Promise: " + initialization.exportName);\n` + + ` }\n` + + ` return new Ok(promise);\n` + + ` } catch (error) {\n` + + ` return new GleamError(error);\n` + + ` }\n` + `}\n\n` + `function toProps(attributes) {\n` + ` const props = {};\n` + @@ -153,7 +206,18 @@ function generateBindingsOrThrow(bindings) { `}\n\n` + `export function binding_error_message(error) {\n` + ` return error instanceof globalThis.Error ? error.message : String(error);\n` + - `}\n`; + `}\n` + ); +} + +export function render_binding_source(bindings) { + return renderBindingsSource(configuredBindings(bindings)) ?? ""; +} + +function generateBindingsOrThrow(bindings) { + const configuredEntries = configuredBindings(bindings); + const content = renderBindingsSource(configuredEntries); + if (content === null) return; const targets = [ "build/packages/glendix/src/glendix/binding_ffi.mjs", "build/dev/javascript/glendix/glendix/binding_ffi.mjs", @@ -180,8 +244,8 @@ function generateBindingsOrThrow(bindings) { } } if (written > 0) { - const moduleNames = Object.keys(config).join(", "); - console.log(`바인딩 생성 완료: ${moduleNames}`); + const moduleNames = configuredEntries.map(entry => entry.moduleName).join(", "); + console.log(`Bindings generated: ${moduleNames}`); } } diff --git a/src/glendix/configuration.gleam b/src/glendix/configuration.gleam index 158f4ea..6000463 100644 --- a/src/glendix/configuration.gleam +++ b/src/glendix/configuration.gleam @@ -38,6 +38,31 @@ pub type ConfigurationError { CompatibilityIsUnsupported(value: String) } +/// Controls what happens after an asynchronous module initializer fails. +pub type InitializationFailurePolicy { + /// Reuses the failed result until the caller explicitly resets the lifecycle. + CacheFailure + /// Allows the next initialization call to start a new attempt. + RetryFailure +} + +/// Describes whether and how one configured module is initialized. +pub type BindingInitialization { + /// The module can be consumed immediately without asynchronous initialization. + NoInitialization + /// The module calls one configured zero-argument Promise-returning export. + Initialize(export_name: String, failure_policy: InitializationFailurePolicy) +} + +/// One configured JavaScript module, its exported components, and lifecycle. +pub type BindingConfiguration { + BindingConfiguration( + module_name: String, + exports: List(String), + initialization: BindingInitialization, + ) +} + /// Reads the current project's `gleam.toml`. /// /// A missing file is represented by an empty configuration. @@ -114,6 +139,24 @@ pub fn compatibility( pub fn bindings( configuration configuration: Configuration, ) -> Result(List(#(String, List(String))), ConfigurationError) { + binding_configurations(configuration) + |> result.map(fn(bindings) { + bindings + |> list.map(fn(binding) { + let BindingConfiguration(module_name, exports, _initialization) = binding + #(module_name, exports) + }) + }) +} + +/// Returns the complete configured JavaScript module definitions. +/// +/// Legacy scalar and array values produce modules without initialization. +/// Extended table values preserve their initializer and failure policy. +@internal +pub fn binding_configurations( + configuration configuration: Configuration, +) -> Result(List(BindingConfiguration), ConfigurationError) { let Configuration(document) = configuration case tom.get_table(document, ["tools", "glendix", "bindings"]) { Ok(table) -> @@ -182,22 +225,20 @@ fn optional_string( fn parse_binding( entry: #(String, tom.Toml), -) -> Result(#(String, List(String)), ConfigurationError) { +) -> Result(BindingConfiguration, ConfigurationError) { let #(module_name, configured_components) = entry let key = ["tools", "glendix", "bindings", module_name] case configured_components { - tom.String(component) -> Ok(#(module_name, [component])) - tom.Array(components) -> { - use components <- result.try( - components - |> list.index_map(fn(component, index) { #(component, index) }) - |> list.try_map(fn(indexed_component) { - let #(component, index) = indexed_component - string_component(component, list.append(key, [int.to_string(index)])) - }), - ) - Ok(#(module_name, components)) - } + tom.String(component) -> + Ok(BindingConfiguration(module_name, [component], NoInitialization)) + tom.Array(components) -> + components + |> parse_components(key) + |> result.map(fn(components) { + BindingConfiguration(module_name, components, NoInitialization) + }) + tom.Table(table) -> parse_binding_table(module_name, table, key) + tom.InlineTable(table) -> parse_binding_table(module_name, table, key) tom.Int(_) -> binding_type_error(key, "Int") tom.Float(_) -> binding_type_error(key, "Float") tom.Infinity(_) -> binding_type_error(key, "Infinity") @@ -207,11 +248,129 @@ fn parse_binding( tom.Time(_) -> binding_type_error(key, "Time") tom.DateTime(_, _, _) -> binding_type_error(key, "DateTime") tom.ArrayOfTables(_) -> binding_type_error(key, "Array") - tom.Table(_) -> binding_type_error(key, "Table") - tom.InlineTable(_) -> binding_type_error(key, "Table") } } +fn parse_binding_table( + module_name: String, + table: dict.Dict(String, tom.Toml), + key: List(String), +) -> Result(BindingConfiguration, ConfigurationError) { + use _ <- result.try(validate_binding_table_keys(table, key)) + use exports <- result.try(parse_optional_exports(table, key)) + use initializer <- result.try(parse_optional_initializer(table, key)) + use failure_policy <- result.try(parse_failure_policy(table, key, initializer)) + let initialization = case initializer { + option.None -> NoInitialization + option.Some(export_name) -> Initialize(export_name, failure_policy) + } + Ok(BindingConfiguration(module_name, exports, initialization)) +} + +fn validate_binding_table_keys( + table: dict.Dict(String, tom.Toml), + key: List(String), +) -> Result(Nil, ConfigurationError) { + let unsupported = + table + |> dict.keys + |> list.filter(fn(name) { + name != "exports" && name != "initializer" && name != "retry" + }) + |> list.sort(by: string.compare) + case unsupported { + [] -> Ok(Nil) + [name, ..] -> + unsupported_value_error( + list.append(key, [name]), + "one of exports, initializer, or retry", + "unsupported key " <> string.inspect(name), + ) + } +} + +fn parse_optional_exports( + table: dict.Dict(String, tom.Toml), + key: List(String), +) -> Result(List(String), ConfigurationError) { + case dict.get(table, "exports") { + Error(_) -> Ok([]) + Ok(tom.String(component)) -> Ok([component]) + Ok(tom.Array(components)) -> + parse_components(components, list.append(key, ["exports"])) + Ok(tom.Int(_)) -> binding_exports_type_error(key, "Int") + Ok(tom.Float(_)) -> binding_exports_type_error(key, "Float") + Ok(tom.Infinity(_)) -> binding_exports_type_error(key, "Infinity") + Ok(tom.Nan(_)) -> binding_exports_type_error(key, "NaN") + Ok(tom.Bool(_)) -> binding_exports_type_error(key, "Bool") + Ok(tom.Date(_)) -> binding_exports_type_error(key, "Date") + Ok(tom.Time(_)) -> binding_exports_type_error(key, "Time") + Ok(tom.DateTime(_, _, _)) -> binding_exports_type_error(key, "DateTime") + Ok(tom.ArrayOfTables(_)) -> binding_exports_type_error(key, "Array") + Ok(tom.Table(_)) -> binding_exports_type_error(key, "Table") + Ok(tom.InlineTable(_)) -> binding_exports_type_error(key, "Table") + } +} + +fn parse_optional_initializer( + table: dict.Dict(String, tom.Toml), + key: List(String), +) -> Result(option.Option(String), ConfigurationError) { + let initializer_key = list.append(key, ["initializer"]) + case dict.get(table, "initializer") { + Error(_) -> Ok(option.None) + Ok(tom.String("")) -> + unsupported_value_error( + initializer_key, + "a non-empty String", + "empty String", + ) + Ok(tom.String(export_name)) -> Ok(option.Some(export_name)) + Ok(value) -> configured_type_error(initializer_key, "String", value) + } +} + +fn parse_failure_policy( + table: dict.Dict(String, tom.Toml), + key: List(String), + initializer: option.Option(String), +) -> Result(InitializationFailurePolicy, ConfigurationError) { + let retry_key = list.append(key, ["retry"]) + case dict.get(table, "retry") { + Error(_) -> Ok(CacheFailure) + Ok(tom.String("never")) -> Ok(CacheFailure) + Ok(tom.String("on-failure")) -> + case initializer { + option.Some(_) -> Ok(RetryFailure) + option.None -> + unsupported_value_error( + retry_key, + "omitted when initializer is not configured", + "String(\"on-failure\")", + ) + } + Ok(tom.String(value)) -> + unsupported_value_error( + retry_key, + "\"never\" or \"on-failure\"", + "String(" <> string.inspect(value) <> ")", + ) + Ok(value) -> configured_type_error(retry_key, "String", value) + } +} + +fn parse_components( + components: List(tom.Toml), + key: List(String), +) -> Result(List(String), ConfigurationError) { + components + |> list.index_map(fn(component, index) { #(component, index) }) + |> list.try_map(fn(indexed_component) { + let #(component, index) = indexed_component + string_component(component, list.append(key, [int.to_string(index)])) + }) +} + fn string_component( component: tom.Toml, key: List(String), @@ -236,9 +395,20 @@ fn string_component( fn binding_type_error( key: List(String), got: String, -) -> Result(#(String, List(String)), ConfigurationError) { +) -> Result(BindingConfiguration, ConfigurationError) { Error(ConfiguredValueHasWrongType( key: key, + expected: "String, Array(String), or Table", + got: got, + )) +} + +fn binding_exports_type_error( + key: List(String), + got: String, +) -> Result(List(String), ConfigurationError) { + Error(ConfiguredValueHasWrongType( + key: list.append(key, ["exports"]), expected: "String or Array(String)", got: got, )) @@ -251,6 +421,44 @@ fn component_type_error( Error(ConfiguredValueHasWrongType(key: key, expected: "String", got: got)) } +fn configured_type_error( + key: List(String), + expected: String, + got: tom.Toml, +) -> Result(value, ConfigurationError) { + Error(ConfiguredValueHasWrongType( + key: key, + expected: expected, + got: tom_value_type(got), + )) +} + +fn unsupported_value_error( + key: List(String), + expected: String, + got: String, +) -> Result(value, ConfigurationError) { + Error(ConfiguredValueHasWrongType(key: key, expected: expected, got: got)) +} + +fn tom_value_type(value: tom.Toml) -> String { + case value { + tom.Int(_) -> "Int" + tom.Float(_) -> "Float" + tom.Infinity(_) -> "Infinity" + tom.Nan(_) -> "NaN" + tom.Bool(_) -> "Bool" + tom.String(_) -> "String" + tom.Date(_) -> "Date" + tom.Time(_) -> "Time" + tom.DateTime(_, _, _) -> "DateTime" + tom.Array(_) -> "Array" + tom.ArrayOfTables(_) -> "Array" + tom.Table(_) -> "Table" + tom.InlineTable(_) -> "Table" + } +} + fn parse_error_message(error: tom.ParseError) -> String { case error { tom.Unexpected(got, expected) -> diff --git a/test/glendix/configuration_test.gleam b/test/glendix/configuration_test.gleam index 5f5ec23..7ad3ef6 100644 --- a/test/glendix/configuration_test.gleam +++ b/test/glendix/configuration_test.gleam @@ -94,6 +94,66 @@ pub fn bindings_are_parsed_and_ordered_deterministically_test() -> Nil { ]) } +/// Verifies extended bindings preserve initialization and retry configuration. +pub fn extended_bindings_parse_initialization_policy_test() -> Nil { + let parsed = + configuration.parse( + "[tools.glendix.bindings]\n" + <> "legacy = \"LegacyComponent\"\n" + <> "\"@ironcalc/workbook\" = { exports = [\"Workbook\"], initializer = \"init\", retry = \"on-failure\" }\n" + <> "\"api-only\" = { initializer = \"initialize\" }\n", + ) + |> should.be_ok + + configuration.binding_configurations(parsed) + |> should.be_ok + |> should.equal([ + configuration.BindingConfiguration( + "@ironcalc/workbook", + ["Workbook"], + configuration.Initialize("init", configuration.RetryFailure), + ), + configuration.BindingConfiguration( + "api-only", + [], + configuration.Initialize("initialize", configuration.CacheFailure), + ), + configuration.BindingConfiguration( + "legacy", + ["LegacyComponent"], + configuration.NoInitialization, + ), + ]) + configuration.bindings(parsed) + |> should.be_ok + |> should.equal([ + #("@ironcalc/workbook", ["Workbook"]), + #("api-only", []), + #("legacy", ["LegacyComponent"]), + ]) +} + +/// Verifies nested tables support a scalar export and default failure caching. +pub fn nested_binding_table_is_parsed_test() -> Nil { + let parsed = + configuration.parse( + "[tools.glendix.bindings.chart]\n" + <> "exports = \"Chart\"\n" + <> "initializer = \"prepare\"\n", + ) + |> should.be_ok + + configuration.binding_configurations(parsed) + |> should.be_ok + |> should.equal([ + configuration.BindingConfiguration( + "chart", + ["Chart"], + configuration.Initialize("prepare", configuration.CacheFailure), + ), + ]) +} + /// Verifies malformed TOML is rejected instead of being partially interpreted. pub fn malformed_toml_returns_parse_error_test() -> Nil { case configuration.parse("[tools.glendix\npm = \"bun\"\n") { @@ -140,6 +200,100 @@ pub fn binding_component_with_wrong_type_returns_schema_error_test() -> Nil { } } +/// Verifies extended export arrays retain indexed schema error paths. +pub fn extended_binding_export_with_wrong_type_returns_schema_error_test() -> Nil { + let parsed = + configuration.parse( + "[tools.glendix.bindings]\n" + <> "components = { exports = [\"Button\", false], initializer = \"init\" }\n", + ) + |> should.be_ok + + case configuration.binding_configurations(parsed) { + Error(configuration.ConfiguredValueHasWrongType(key, expected, got)) -> { + key + |> should.equal([ + "tools", + "glendix", + "bindings", + "components", + "exports", + "1", + ]) + expected |> should.equal("String") + got |> should.equal("Bool") + } + Ok(_) -> should.fail() + Error(_) -> should.fail() + } +} + +/// Verifies unsupported retry modes fail with the exact configured key. +pub fn unsupported_binding_retry_policy_returns_schema_error_test() -> Nil { + let parsed = + configuration.parse( + "[tools.glendix.bindings]\n" + <> "components = { initializer = \"init\", retry = \"always\" }\n", + ) + |> should.be_ok + + case configuration.binding_configurations(parsed) { + Error(configuration.ConfiguredValueHasWrongType(key, expected, got)) -> { + key + |> should.equal([ + "tools", + "glendix", + "bindings", + "components", + "retry", + ]) + expected |> should.equal("\"never\" or \"on-failure\"") + got |> should.equal("String(\"always\")") + } + Ok(_) -> should.fail() + Error(_) -> should.fail() + } +} + +/// Verifies retry configuration cannot be silently ignored without an initializer. +pub fn retry_without_initializer_returns_schema_error_test() -> Nil { + let parsed = + configuration.parse( + "[tools.glendix.bindings]\n" + <> "components = { exports = [\"Button\"], retry = \"on-failure\" }\n", + ) + |> should.be_ok + + case configuration.binding_configurations(parsed) { + Error(_) -> Nil + Ok(_) -> should.fail() + } +} + +/// Verifies misspelled extended keys are rejected instead of ignored. +pub fn unknown_extended_binding_key_returns_schema_error_test() -> Nil { + let parsed = + configuration.parse( + "[tools.glendix.bindings]\n" + <> "components = { exports = [\"Button\"], initialise = \"init\" }\n", + ) + |> should.be_ok + + case configuration.binding_configurations(parsed) { + Error(configuration.ConfiguredValueHasWrongType(key, _, _)) -> + key + |> should.equal([ + "tools", + "glendix", + "bindings", + "components", + "initialise", + ]) + Ok(_) -> should.fail() + Error(_) -> should.fail() + } +} + /// Verifies unsupported compatibility values remain actionable command errors. pub fn unsupported_compatibility_has_actionable_message_test() -> Nil { let parsed = diff --git a/test/glendix_test.gleam b/test/glendix_test.gleam index 77c23bd..ca168d2 100644 --- a/test/glendix_test.gleam +++ b/test/glendix_test.gleam @@ -1,9 +1,11 @@ //// Exercises Glendix pure domain logic and JavaScript FFI contracts. //// +import gleam/dynamic import gleam/javascript/array as javascript_array import gleam/javascript/promise import gleam/list +import gleam/option import gleam/string import gleeunit import gleeunit/should @@ -18,6 +20,7 @@ import glendix/js/object import glendix/js/promise as glendix_promise import glendix/lustre import lustre/attribute +import lustre/effect import lustre/element import lustre/element/html import redraw @@ -169,6 +172,275 @@ pub fn binding_element_contract_test() -> Nil { |> should.equal("section#binding-test") } +/// Verifies generated lifecycle bindings use deterministic static imports. +pub fn binding_generator_uses_static_initializer_imports_test() -> Nil { + let source = + cmd.render_binding_source(bindings: [ + #("@ironcalc/workbook", ["Workbook", "Workbook"], "init", "on-failure"), + #("component-only", ["Chart"], "", "never"), + #("api-only", [], "initialize", "never"), + ]) + + source + |> string.contains("from \"@ironcalc/workbook\";") + |> should.be_true + source + |> string.contains("run: () => __glendix_module_0_export_1()") + |> should.be_true + source + |> string.contains("\"Workbook\": __glendix_module_0_export_0") + |> should.be_true + source + |> string.contains("import(") + |> should.be_false + source + |> string.contains("\"api-only\": {") + |> should.be_true +} + +/// Verifies configurations with no usable exports preserve no-output behavior. +pub fn binding_generator_empty_configuration_has_no_output_test() -> Nil { + cmd.render_binding_source(bindings: [ + #("empty", [], "", "never"), + ]) + |> should.equal("") +} + +/// Verifies modules without an initializer are immediately reusable. +pub fn binding_module_without_initializer_is_ready_test() -> promise.Promise( + Nil, +) { + let module = test_initialization_module("none", "never") + let initialization = binding.initialization(module) + binding.initialization_status(initialization) + |> should.equal(binding.Ready) + binding.initialized_module(initialization) + |> should.equal(option.Some(module)) + let #(unchanged, attempt) = binding.initialize(initialization) + binding.initialization_status(unchanged) + |> should.equal(binding.Ready) + attempt + |> promise.map(fn(outcome) { should.equal(outcome, Ok(Nil)) }) +} + +/// Verifies successful initialization unlocks rendering and non-React reuse. +pub fn binding_initialization_success_reuses_ready_module_test() -> promise.Promise( + Nil, +) { + let module = test_initialization_module("success", "never") + let initialization = binding.initialization(module) + let #(loading, attempt) = binding.initialize(initialization) + binding.initialization_status(loading) + |> should.equal(binding.Initializing) + use outcome <- promise.await(attempt) + let ready = binding.settle_initialization(loading, with: outcome) + binding.initialization_status(ready) + |> should.equal(binding.Ready) + initialization_attempt_count(module) + |> should.equal(1) + case binding.initialized_module(ready) { + option.Some(ready_module) -> { + binding.resolve(ready_module, "View") + |> should.be_ok + |> binding.element([], []) + |> rendered_tree_summary + |> should.equal("section") + non_react_module_value(ready_module) + |> should.equal(42) + } + option.None -> should.fail() + } + let #(reused, second) = binding.initialize(ready) + binding.initialization_status(reused) + |> should.equal(binding.Ready) + initialization_attempt_count(module) + |> should.equal(1) + second + |> promise.map(fn(second_outcome) { should.equal(second_outcome, Ok(Nil)) }) +} + +/// Verifies concurrent consumers receive the exact same in-flight Promise. +pub fn binding_initialization_concurrent_calls_share_one_flight_test() -> promise.Promise( + Nil, +) { + let module = test_initialization_module("controlled", "never") + let #(loading, first) = + module + |> binding.initialization + |> binding.initialize + let #(shared, second) = binding.initialize(loading) + initialization_attempt_count(module) + |> should.equal(1) + initialization_promises_are_same(first, second) + |> should.be_true + resolve_initialization(module) + promise.await_list([first, second]) + |> promise.map(fn(outcomes) { + outcomes + |> should.equal([Ok(Nil), Ok(Nil)]) + binding.initialization_status(shared) + |> should.equal(binding.Initializing) + }) +} + +/// Verifies the default policy caches a rejected attempt until explicit reset. +pub fn binding_initialization_cache_failure_requires_reset_test() -> promise.Promise( + Nil, +) { + let module = test_initialization_module("reject", "never") + let #(loading, first) = + module + |> binding.initialization + |> binding.initialize + use first_outcome <- promise.await(first) + let failed = binding.settle_initialization(loading, with: first_outcome) + binding.initialization_status(failed) + |> should.equal( + binding.Failed(binding.InitializationRejected( + "@test/async-module", + "init", + "WebAssembly load failed", + )), + ) + let #(cached, second) = binding.initialize(failed) + initialization_attempt_count(module) + |> should.equal(1) + use second_outcome <- promise.await(second) + second_outcome + |> should.equal(first_outcome) + let reset = binding.reset_initialization(cached) + binding.initialization_status(reset) + |> should.equal(binding.Uninitialized) + let #(_retrying, third) = binding.initialize(reset) + initialization_attempt_count(module) + |> should.equal(2) + third + |> promise.map(fn(outcome) { + case outcome { + Error(_) -> Nil + Ok(_) -> should.fail() + } + }) +} + +/// Verifies the retry policy starts a new attempt after a recorded failure. +pub fn binding_initialization_retry_policy_restarts_after_failure_test() -> promise.Promise( + Nil, +) { + let module = test_initialization_module("reject", "on-failure") + let #(loading, first) = + module + |> binding.initialization + |> binding.initialize + use first_outcome <- promise.await(first) + let failed = binding.settle_initialization(loading, with: first_outcome) + let #(_retrying, second) = binding.initialize(failed) + initialization_attempt_count(module) + |> should.equal(2) + second + |> promise.map(fn(outcome) { + case outcome { + Error(_) -> Nil + Ok(_) -> should.fail() + } + }) +} + +/// Verifies synchronous throws become start failures with retained context. +pub fn binding_initialization_synchronous_throw_is_typed_test() -> promise.Promise( + Nil, +) { + let module = test_initialization_module("throw", "never") + let #(failed, attempt) = + module + |> binding.initialization + |> binding.initialize + binding.initialization_status(failed) + |> should.equal( + binding.Failed(binding.InitializationCouldNotStart( + "@test/async-module", + "init", + "Initializer threw synchronously", + )), + ) + attempt + |> promise.map(fn(outcome) { + outcome + |> should.equal( + Error(binding.InitializationCouldNotStart( + "@test/async-module", + "init", + "Initializer threw synchronously", + )), + ) + }) +} + +/// Verifies non-Promise initializer returns fail before entering flight state. +pub fn binding_initialization_non_promise_is_typed_test() -> promise.Promise( + Nil, +) { + let module = test_initialization_module("non-promise", "never") + let #(_failed, attempt) = + module + |> binding.initialization + |> binding.initialize + attempt + |> promise.map(fn(outcome) { + outcome + |> should.equal( + Error(binding.InitializationCouldNotStart( + "@test/async-module", + "init", + "Initializer must return a Promise: init", + )), + ) + }) +} + +/// Verifies reset cannot replace an in-flight attempt and create a race. +pub fn binding_initialization_reset_while_loading_is_ignored_test() -> Nil { + let module = test_initialization_module("controlled", "never") + let #(loading, _attempt) = + module + |> binding.initialization + |> binding.initialize + loading + |> binding.reset_initialization + |> binding.initialization_status + |> should.equal(binding.Initializing) + initialization_attempt_count(module) + |> should.equal(1) + resolve_initialization(module) +} + +/// Verifies the Lustre helper dispatches one completion for the shared attempt. +pub fn binding_initialization_effect_dispatches_completion_test() -> promise.Promise( + Nil, +) { + let counter = new_promise_callback_counter() + let module = test_initialization_module("success", "never") + let #(_loading, initialization_effect) = + module + |> binding.initialization + |> binding.initialization_effect(to_message: fn(outcome) { outcome }) + effect.perform( + initialization_effect, + fn(_message) { increment_promise_callback_counter(counter) }, + fn(_name, _data) { Nil }, + fn(_selector) { Nil }, + fn() { dynamic.string("") }, + fn(_name, _value) { Nil }, + fn(_name, _decoder) { Nil }, + fn(_name) { Nil }, + ) + promise.wait(0) + |> promise.map(fn(_) { + promise_callback_count(counter) + |> should.equal(1) + }) +} + /// Verifies property type conversion preserves all supported variants. pub fn define_property_type_round_trip_test() -> Nil { model.all_types() @@ -431,6 +703,24 @@ fn rendered_tree_summary(tree: redraw.Element) -> String @external(javascript, "./glendix_test_ffi.mjs", "test_component") fn test_component() -> binding.JsComponent +@external(javascript, "./glendix_test_ffi.mjs", "test_initialization_module") +fn test_initialization_module(mode: String, retry: String) -> binding.JsModule + +@external(javascript, "./glendix_test_ffi.mjs", "initialization_attempt_count") +fn initialization_attempt_count(module: binding.JsModule) -> Int + +@external(javascript, "./glendix_test_ffi.mjs", "resolve_initialization") +fn resolve_initialization(module: binding.JsModule) -> Nil + +@external(javascript, "./glendix_test_ffi.mjs", "initialization_promises_are_same") +fn initialization_promises_are_same( + first: promise.Promise(Result(Nil, binding.InitializationError)), + second: promise.Promise(Result(Nil, binding.InitializationError)), +) -> Bool + +@external(javascript, "./glendix_test_ffi.mjs", "non_react_module_value") +fn non_react_module_value(module: binding.JsModule) -> Int + @external(javascript, "./glendix_test_ffi.mjs", "generated_rollup_config_source") fn generated_rollup_config_source(with_secondary_widget: Bool) -> String diff --git a/test/glendix_test_ffi.mjs b/test/glendix_test_ffi.mjs index 592e54f..d77e4d6 100644 --- a/test/glendix_test_ffi.mjs +++ b/test/glendix_test_ffi.mjs @@ -66,6 +66,59 @@ export function test_component() { return "section"; } +export function test_initialization_module(mode, retry) { + const state = { + attempts: 0, + resolve: null, + reject: null, + }; + const run = () => { + state.attempts += 1; + switch (mode) { + case "success": + return Promise.resolve(undefined); + case "reject": + return Promise.reject(new Error("WebAssembly load failed")); + case "throw": + throw new Error("Initializer threw synchronously"); + case "non-promise": + return undefined; + case "controlled": + return new Promise((resolve, reject) => { + state.resolve = resolve; + state.reject = reject; + }); + default: + throw new Error(`Unsupported initialization test mode: ${mode}`); + } + }; + return { + name: "@test/async-module", + exports: { View: "section" }, + apiValue: 42, + initialization: mode === "none" + ? null + : { exportName: "init", retry, run }, + state, + }; +} + +export function initialization_attempt_count(mod) { + return mod.state.attempts; +} + +export function resolve_initialization(mod) { + mod.state.resolve?.(undefined); +} + +export function initialization_promises_are_same(first, second) { + return first === second; +} + +export function non_react_module_value(mod) { + return mod.apiValue; +} + export function generated_rollup_config_source(withSecondaryWidget) { return render_rollup_config(withSecondaryWidget ? ["SecondaryWidget"] : []); }