From 33deed5d602046df52dccde582e1480d6b289ddd Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 16 Sep 2026 19:31:41 +0200 Subject: [PATCH 1/2] Add an explicitly enabled single-result Scene query view Use a private native Arc query instance with context forwarding, cancellation, generation guards and visible result states. Select only an explicit own scalar model field; keep arguments and binding selection under host control. Verified 39 native Arc HTTP-boundary cases, 171 component regressions, affected type/bundle builds and lint. Independent runtime review approved the bounded increment; editable Scene input and deployed-backend acceptance remain follow-up work. --- Source/JavaScript/components/README.md | 53 +++- .../JavaScript/components/cratisComponents.ts | 3 +- .../components/cratisComponentsPackage.ts | 3 +- .../components/data/SceneSingleResult.tsx | 29 ++ .../components/data/SingleResultRuntime.tsx | 89 ++++++ .../components/data/SingleResultStatus.tsx | 13 + .../when_executing_a_native_query.tsx | 290 ++++++++++++++++++ .../when_gating_the_lazy_runtime.tsx | 60 ++++ Source/JavaScript/components/data/index.ts | 1 + .../when_describing_single_result.ts | 20 ++ Source/JavaScript/components/package.json | 12 + 11 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 Source/JavaScript/components/data/SceneSingleResult.tsx create mode 100644 Source/JavaScript/components/data/SingleResultRuntime.tsx create mode 100644 Source/JavaScript/components/data/SingleResultStatus.tsx create mode 100644 Source/JavaScript/components/data/for_SceneSingleResult/when_executing_a_native_query.tsx create mode 100644 Source/JavaScript/components/data/for_SceneSingleResult/when_gating_the_lazy_runtime.tsx create mode 100644 Source/JavaScript/components/for_cratisComponentsPackage/when_describing_single_result.ts diff --git a/Source/JavaScript/components/README.md b/Source/JavaScript/components/README.md index 97536cd6..1242b639 100644 --- a/Source/JavaScript/components/README.md +++ b/Source/JavaScript/components/README.md @@ -72,7 +72,8 @@ unbound table costs one dashed box, not the whole screen. ### Arc is loaded lazily `@cratis/arc`, `@cratis/arc.react` and `@cratis/fundamentals` are peer dependencies of `@cratis/components` -that the *host* supplies. They are not installed in this repository, and a design surface is not a host. +that the *host* supplies. This package also declares Arc and Arc React as optional peers and pins +22.16.1 as development dependencies for native integration tests. A design surface is not a host. Every adapter that reaches them does so through a dynamic `import()` inside `React.lazy`, wrapped in `ArcRuntimeBoundary` (`Suspense` + the library's own `ErrorBoundary`). A screen built only from the library's @@ -87,6 +88,54 @@ This splits the library cleanly, and the split is the library's own: The Storybook build externalizes the Arc packages (`.storybook/main.ts`) so the lazy chunk can be emitted with its imports left bare. No story loads that chunk, because no story registers a binding. +## Optional single-result view (read-only) + +`Cratis.Components:singleResult` is a narrow adapter for an optional **single model**, not a collection. +For example, a host registers its generated `ProjectName` proxy and supplies these ExternalComponent +properties (the identifier comes from the host's committed input, never a guessed project): + +```typescript +registerQuery('ProjectName', ProjectName); +const properties = { + query: 'ProjectName', + queryArguments: Object.freeze({ projectId: committedProjectId }), + enabled: true, + resultField: 'name', +}; +``` + +Use the actual own-field name of the generated model. `resultField` selects exactly one own data field: +no dotted-path traversal, getters, inherited members, expressions, JSON display or result-binding DSL. +Strings, finite numbers and booleans render as escaped text, including `0`, `false` and empty strings. +An absent/non-scalar field is a visible failure, not a missing model. A successful null/undefined model +is `Not found`; unsuccessful, unauthorized, invalid and exception envelopes are `Unable to load result`. +Idle, loading and failure states never display server exception messages or stacks. + +`enabled` defaults to false and must be exactly true; the element must also be enabled. The host owns +input controls, validation feedback and committing/replacing immutable `queryArguments` objects. Even +parameterless queries require an explicit `{}`. Missing bindings take precedence over all other checks +and never import Arc. Disabled or malformed adapter properties do not import the lazy runtime either. +Native Arc `perform` owns required-parameter and generated-validator checks before HTTP; Scene adds no +business validation and forwards the argument object by identity. + +The private lazy runtime creates a fresh native proxy per request generation, configures all four Arc +context connection/header values, and aborts on replacement or unmount. It also ignores late completion +when cancellation is ignored, masks previous results on the input-changing render, and responds to +header-callback identity and `queryVersion` changes. Hosts must replace the callback or bump the version +when credentials change invisibly inside a stable callback. There is no shared cache, command-triggered +refresh, polling, retry button, or observable-query subscription. + +Published Arc 22.16.1 itself normalizes top-level falsy primitive payloads to null in `QueryResult`. +This adapter therefore promises scalar **fields of models**, not top-level primitive queries; it does +not fork Arc deserialization to recover those payloads. `isReady: false` is a failure for this one-shot +view rather than an invented retry protocol. Enumerable proxies are rejected before execution. + +Tests use a real `QueryFor` subclass with unmodified `perform`, native validation and only the HTTP +boundary replaced. Separate lazy-boundary doubles test Arc-free gating, not native client correctness. +They do not prove a generated server route, backend persistence or end-to-end editable input. Scene #39 +must remain open: editable Scene parameter binding, input/commit semantics, cross-component binding and +a general result-binding model are still unimplemented. No default profile, generator or schema changed. + ## Naming and shadowing Bare names are `lowerCamelCase` and abstract — `dataTable`, not `DataTableForQuery` — registered under @@ -149,7 +198,7 @@ both packages write themes in one language: `primary.color`, `primary.contrastCo ``` bindings/ the registry, BindingKind, BoundConstructor, MissingBinding, ArcRuntimeBoundary pages/ page, dataPage, formElement -data/ dataTable, table, observableDataTable +data/ dataTable, table, observableDataTable, singleResult forms/ commandForm; forms/fields/ the twelve field types dialogs/ dialog, confirmationDialog, busyIndicatorDialog, commandDialog, stepperCommandDialog common/ icon, tooltip, dropdown, errorBoundary diff --git a/Source/JavaScript/components/cratisComponents.ts b/Source/JavaScript/components/cratisComponents.ts index 5702ec76..f39f2b40 100644 --- a/Source/JavaScript/components/cratisComponents.ts +++ b/Source/JavaScript/components/cratisComponents.ts @@ -3,7 +3,7 @@ import { ComponentRegistry, componentRegistryKey } from '@cratis/scene.react'; import { SceneDataPage, SceneFormElement, ScenePage } from './pages'; -import { SceneDataTable, SceneObservableDataTable } from './data'; +import { SceneDataTable, SceneObservableDataTable, SceneSingleResult } from './data'; import { SceneCalendarField, SceneCheckboxField, @@ -49,6 +49,7 @@ export const cratisComponents: ComponentRegistry = { [componentRegistryKey(cratisComponentsPackageName, 'dataPage')]: SceneDataPage, [componentRegistryKey(cratisComponentsPackageName, 'formElement')]: SceneFormElement, + [componentRegistryKey(cratisComponentsPackageName, 'singleResult')]: SceneSingleResult, [componentRegistryKey(cratisComponentsPackageName, 'dataTable')]: SceneDataTable, [componentRegistryKey(cratisComponentsPackageName, 'table')]: SceneDataTable, [componentRegistryKey(cratisComponentsPackageName, 'observableDataTable')]: SceneObservableDataTable, diff --git a/Source/JavaScript/components/cratisComponentsPackage.ts b/Source/JavaScript/components/cratisComponentsPackage.ts index b191c7bf..a43a8e8f 100644 --- a/Source/JavaScript/components/cratisComponentsPackage.ts +++ b/Source/JavaScript/components/cratisComponentsPackage.ts @@ -36,6 +36,7 @@ export const cratisComponentsPackageManifest: ScenePackage = { 'page', 'dataPage', 'formElement', + 'singleResult', 'dataTable', 'table', 'observableDataTable', @@ -88,7 +89,7 @@ export const cratisComponentsPackageManifest: ScenePackage = { themes: [], displayName: 'Cratis Components', - description: "Cratis' Arc-bound data, form and dialog composites, built on PrimeReact and Tailwind. dataTable and table accept optional object-valued queryArguments for collection queries.", + description: "Cratis' Arc-bound data, form and dialog composites, built on PrimeReact and Tailwind. dataTable and table accept optional object-valued queryArguments for collection queries. singleResult displays one read-only scalar own resultField of an optional model using an explicit query, object queryArguments and enabled: true.", module: '@cratis/scene.components', license: 'MIT', licenseUrl: 'https://github.com/Cratis/Scene/blob/main/LICENSE', diff --git a/Source/JavaScript/components/data/SceneSingleResult.tsx b/Source/JavaScript/components/data/SceneSingleResult.tsx new file mode 100644 index 00000000..96893326 --- /dev/null +++ b/Source/JavaScript/components/data/SceneSingleResult.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; +import { booleanProperty, objectProperty, stringProperty } from '../properties'; +import { SingleResultStatus } from './SingleResultStatus'; + +const SingleResultRuntime = lazy(() => import('./SingleResultRuntime')); + +/** Read-only scalar field of an optional single model; the host owns and commits the input. */ +export function SceneSingleResult({ element }: RegisteredComponentProps) { + const { name, target } = resolveElementBinding(element, BindingKind.Query); + if (!target) return ; + + // Nothing, including loading Arc, happens until the host explicitly commits its input. + if (!element.isEnabled || booleanProperty(element.properties, 'enabled') !== true) { + return ; + } + + const queryArguments = objectProperty(element.properties, 'queryArguments'); + const resultField = stringProperty(element.properties, 'resultField'); + if (!queryArguments || !resultField) return ; + + return + + ; +} diff --git a/Source/JavaScript/components/data/SingleResultRuntime.tsx b/Source/JavaScript/components/data/SingleResultRuntime.tsx new file mode 100644 index 00000000..6f3ee8a6 --- /dev/null +++ b/Source/JavaScript/components/data/SingleResultRuntime.tsx @@ -0,0 +1,89 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useContext, useEffect, useMemo, useState } from 'react'; +import { ArcContext } from '@cratis/arc.react'; +import type { QueryFor } from '@cratis/arc/queries'; +import type { BoundConstructor } from '../bindings/BoundConstructor'; +import { SingleResultStatus } from './SingleResultStatus'; + +interface Props { + query: BoundConstructor; + queryArguments: Record; + resultField: string; +} + +type Outcome = { state: 'failure' | 'notFound' } | { state: 'success'; value: string }; + +/** + * Private execution owner. Arc owns validation, routing, HTTP and deserialization; this component + * owns only request lifetime and presentation. No shared cache can hand B the result belonging to A. + */ +export default function SingleResultRuntime({ query, queryArguments, resultField }: Props) { + const arc = useContext(ArcContext); + const { microservice, apiBasePath, origin, httpHeadersCallback, queryVersion } = arc; + // Identity is deliberate: hosts replace immutable committed arguments rather than mutating them. + const request = useMemo(() => ({ query, queryArguments, resultField, microservice, apiBasePath, origin, httpHeadersCallback, queryVersion }), + [query, queryArguments, resultField, microservice, apiBasePath, origin, httpHeadersCallback, queryVersion]); + const [settled, setSettled] = useState<{ request: typeof request; outcome: Outcome }>(); + + useEffect(() => { + // Each effect setup is a generation, including StrictMode's setup/cleanup/setup replay. + let current = true; + let instance: QueryFor> | undefined; + const publish = (outcome: Outcome) => { + if (current) setSettled({ request, outcome }); + }; + const execute = async () => { + try { + // The registry intentionally stores constructors without an eager Arc dependency. + instance = new request.query() as QueryFor>; + if (instance.enumerable !== false) { + publish({ state: 'failure' }); + return; + } + instance.setMicroservice(request.microservice); + instance.setApiBasePath(request.apiBasePath ?? ''); + instance.setOrigin(request.origin ?? ''); + instance.setHttpHeadersCallback(request.httpHeadersCallback ?? (() => ({}))); + // Native perform validates required arguments and proxy rules before HTTP. No copying, + // defaults, business validation, route construction or array wrapping happens here. + const result = await instance.perform(request.queryArguments); + if (!current) return; + if (result.isSuccess !== true || result.isAuthorized !== true || result.isValid !== true || result.hasExceptions !== false || result.isReady === false) { + publish({ state: 'failure' }); + return; + } + if (result.data === null || result.data === undefined) { + publish({ state: 'notFound' }); + return; + } + if (typeof result.data !== 'object' || Array.isArray(result.data)) { + publish({ state: 'failure' }); + return; + } + // Only one own data field, never a path, inherited member, getter or expression. + const descriptor = Object.getOwnPropertyDescriptor(result.data, request.resultField); + const value: unknown = descriptor && 'value' in descriptor ? descriptor.value : undefined; + if (typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) { + publish({ state: 'success', value: String(value) }); + } else { + publish({ state: 'failure' }); + } + } catch { + // Includes native AbortError rejections. Disposed generations cannot publish or leak + // an unhandled promise, even when the HTTP boundary ignores cancellation. + publish({ state: 'failure' }); + } + }; + void execute(); + return () => { + current = false; + instance?.abortController?.abort(); + }; + }, [request]); + + // Mask old data during the render that changes inputs, before effect cleanup/setup runs. + if (settled?.request !== request) return ; + return ; +} diff --git a/Source/JavaScript/components/data/SingleResultStatus.tsx b/Source/JavaScript/components/data/SingleResultStatus.tsx new file mode 100644 index 00000000..e670611f --- /dev/null +++ b/Source/JavaScript/components/data/SingleResultStatus.tsx @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +type State = 'idle' | 'loading' | 'notFound' | 'failure' | 'success'; + +const messages = { idle: 'Idle', loading: 'Loading', notFound: 'Not found', failure: 'Unable to load result', success: '' }; + +/** Internal shared presentation; never exposes query exception messages or server stack traces. */ +export function SingleResultStatus({ state, value }: { state: State; value?: string }) { + return + {state === 'success' ? value : messages[state]} + ; +} diff --git a/Source/JavaScript/components/data/for_SceneSingleResult/when_executing_a_native_query.tsx b/Source/JavaScript/components/data/for_SceneSingleResult/when_executing_a_native_query.tsx new file mode 100644 index 00000000..05fec018 --- /dev/null +++ b/Source/JavaScript/components/data/for_SceneSingleResult/when_executing_a_native_query.tsx @@ -0,0 +1,290 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { expect, vi } from 'vitest'; +import { StrictMode } from 'react'; +import { ArcContext, type ArcConfiguration } from '@cratis/arc.react'; +import { QueryFor, QueryHttpMethod, QueryValidator } from '@cratis/arc/queries'; +import { clearBindings, registerQuery } from '../../bindings'; +import { externalComponent } from '../../given'; +import { SceneSingleResult } from '../SceneSingleResult'; + +class ProjectName extends QueryFor { + readonly route = '/projects/{projectId}/name'; + readonly parameterDescriptors = []; + readonly requiredRequestParameters = ['projectId']; + defaultValue = undefined; + + constructor() { + super(Object, false); + this.setHttpMethod(QueryHttpMethod.Get); + } +} + +class ProjectNames extends QueryFor { + readonly route = '/projects'; + readonly parameterDescriptors = []; + readonly requiredRequestParameters = []; + defaultValue = []; + + constructor() { super(Object, true); } +} + +class ProjectArgumentsValidator extends QueryValidator<{ projectId: string }> { + static received: unknown; + + constructor() { + super(); + this.ruleFor(args => { + ProjectArgumentsValidator.received = args; + return args.projectId; + }).minLength(2); + } +} + +class ValidatedProjectName extends ProjectName { + readonly validation = new ProjectArgumentsValidator(); +} + +function envelope(data: unknown, overrides: Record = {}) { + return { + data, isSuccess: true, isAuthorized: true, isValid: true, hasExceptions: false, + validationResults: [], exceptionMessages: [], exceptionStackTrace: '', + paging: { page: 0, size: 0, totalItems: 0, totalPages: 0 }, ...overrides, + }; +} + +function response(data: unknown, overrides: Record = {}) { + return { ok: true, status: 200, json: async () => envelope(data, overrides) } as Response; +} + +function deferred() { + let resolve!: (value: Response) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +describe('when executing an optional single result with native Arc 22.16.1', () => { + const headers = () => ({ 'x-host': 'one' }); + const context: ArcConfiguration = { microservice: 'projects', origin: 'https://example.test', apiBasePath: '/backend', httpHeadersCallback: headers }; + const fetch = vi.fn(); + + function view(queryArguments: unknown = { projectId: 'A' }, arc = context, properties: Record = {}) { + return + + ; + } + + beforeEach(() => { + clearBindings(); + registerQuery('ProjectName', ProjectName); + registerQuery('ProjectNames', ProjectNames); + registerQuery('ValidatedProjectName', ValidatedProjectName); + fetch.mockReset(); + vi.stubGlobal('fetch', fetch); + }); + + afterEach(() => { + cleanup(); + clearBindings(); + vi.unstubAllGlobals(); + }); + + it('uses the native URL, arguments and context headers without changing the host object', async () => { + const args = Object.freeze({ projectId: 'AA', locale: 'en' }); + fetch.mockResolvedValue(response({ name: 'Project A' })); + await act(async () => { render(view(args, context, { query: 'ValidatedProjectName' })); }); + expect(await screen.findByText('Project A')).not.toBeNull(); + expect(document.querySelector('b')).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toBe('https://example.test/backend/projects/AA/name?locale=en'); + expect(ProjectArgumentsValidator.received).toBe(args); + expect(new Headers(init?.headers).get('x-host')).toBe('one'); + expect(new Headers(init?.headers).get('x-cratis-microservice')).toBe('projects'); + expect(args).toEqual({ projectId: 'AA', locale: 'en' }); + }); + + for (const value of [false, 0, '']) { + it(`renders scalar own-field ${JSON.stringify(value)} as present`, async () => { + fetch.mockResolvedValue(response({ name: value })); + await act(async () => { render(view()); }); + await waitFor(() => expect(screen.getByRole('status').getAttribute('data-scene-single-result')).toBe('success')); + expect(screen.getByRole('status').textContent).toBe(String(value)); + }); + } + + for (const data of [null, undefined]) { + it(`shows not found for successful ${String(data)}`, async () => { + fetch.mockResolvedValue(response(data)); + await act(async () => { render(view()); }); + expect(await screen.findByText('Not found')).not.toBeNull(); + }); + } + + for (const failure of [{ isSuccess: false }, { isAuthorized: false }, { isValid: false }, { hasExceptions: true }]) { + it(`prioritizes failure over missing data: ${JSON.stringify(failure)}`, async () => { + fetch.mockResolvedValue(response(null, { ...failure, exceptionMessages: ['secret'], exceptionStackTrace: 'server stack' })); + await act(async () => { render(view()); }); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(screen.queryByText('Not found')).toBeNull(); + expect(document.body.textContent).not.toMatch(/secret|server stack/); + }); + } + + for (const args of [{}, { projectId: '' }, { projectId: null }, [], null, 'A', undefined]) { + it(`does not send invalid or missing arguments: ${JSON.stringify(args)}`, async () => { + await act(async () => { render(view(args, context, { queryArguments: args })); }); + expect(await screen.findByRole('alert')).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + } + + it('lets the native proxy validator reject arguments without HTTP', async () => { + await act(async () => { render(view({ projectId: 'A' }, context, { query: 'ValidatedProjectName' })); }); + expect(await screen.findByRole('alert')).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + for (const enabled of [false, undefined, 'true']) { + it(`is idle unless explicitly enabled: ${String(enabled)}`, async () => { + await act(async () => { render(view({ projectId: 'A' }, context, { enabled })); }); + expect(screen.getByText('Idle')).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + } + + it('never chooses another query when the binding is missing', async () => { + await act(async () => { render(view({}, context, { query: 'Missing' })); }); + expect(screen.getByText(/Unresolved query binding 'Missing'/)).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('aborts A and ignores reverse completion when the transport ignores abort', async () => { + const a = deferred(); + const b = deferred(); + fetch.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view()); }); + expect(screen.getByText('Loading')).not.toBeNull(); + const signal = fetch.mock.calls[0][1]?.signal; + await act(async () => { mounted.rerender(view({ projectId: 'B' })); }); + expect(signal?.aborted).toBe(true); + await act(async () => { b.resolve(response({ name: 'B' })); }); + expect(screen.getByText('B')).not.toBeNull(); + await act(async () => { a.resolve(response({ name: 'A' })); }); + expect(screen.queryByText('A')).toBeNull(); + expect(screen.getByText('B')).not.toBeNull(); + }); + + it('hides a settled A immediately on B and aborts B on disposal', async () => { + const b = deferred(); + fetch.mockResolvedValueOnce(response({ name: 'A' })).mockReturnValueOnce(b.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view()); }); + expect(screen.getByText('A')).not.toBeNull(); + await act(async () => { mounted.rerender(view({ projectId: 'B' })); }); + expect(screen.queryByText('A')).toBeNull(); + expect(screen.getByText('Loading')).not.toBeNull(); + mounted.unmount(); + expect(fetch.mock.calls[1][1]?.signal?.aborted).toBe(true); + await act(async () => { b.reject(new DOMException('Aborted', 'AbortError')); }); + }); + + it('reexecutes for header-only context changes and ignores the old response', async () => { + const a = deferred(); + const b = deferred(); + const args = { projectId: 'A' }; + fetch.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view(args)); }); + await act(async () => { mounted.rerender(view(args, { ...context, httpHeadersCallback: () => ({ 'x-host': 'two' }) })); }); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + expect(new Headers(fetch.mock.calls[1][1]?.headers).get('x-host')).toBe('two'); + await act(async () => { b.resolve(response({ name: 'New context' })); a.resolve(response({ name: 'Old context' })); }); + expect(screen.getByText('New context')).not.toBeNull(); + expect(screen.queryByText('Old context')).toBeNull(); + }); + + it('rejects enumerable proxies before HTTP rather than adapting them to a single model', async () => { + await act(async () => { render(view({}, context, { query: 'ProjectNames' })); }); + expect(await screen.findByRole('alert')).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('handles StrictMode disposal without an unhandled abort promise', async () => { + const a = deferred(); + const b = deferred(); + fetch.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise); + await act(async () => { render({view()}); }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + await act(async () => { + a.reject(new DOMException('Aborted', 'AbortError')); + b.resolve(response({ name: 'Current generation' })); + }); + expect(screen.getByText('Current generation')).not.toBeNull(); + }); + + it('ignores successful completion after unmount even if HTTP ignores abort', async () => { + const pending = deferred(); + fetch.mockReturnValueOnce(pending.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view()); }); + mounted.unmount(); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(response({ name: 'Disposed' })); }); + expect(screen.queryByText('Disposed')).toBeNull(); + }); + + it('turns transport rejection and malformed envelopes into safe failure', async () => { + fetch.mockRejectedValueOnce(new Error('private network detail')); + let mounted!: ReturnType; + await act(async () => { mounted = render(view()); }); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + fetch.mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) } as Response); + await act(async () => { mounted.rerender(view({ projectId: 'B' })); }); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(document.body.textContent).not.toContain('private network detail'); + }); + + it('aborts and clears a pending request when disabled', async () => { + const pending = deferred(); + fetch.mockReturnValueOnce(pending.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view()); }); + await act(async () => { mounted.rerender(view({ projectId: 'A' }, context, { enabled: false })); }); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(response({ name: 'Obsolete' })); }); + expect(screen.getByText('Idle')).not.toBeNull(); + expect(screen.queryByText('Obsolete')).toBeNull(); + }); + + for (const change of [{ origin: 'https://other.test' }, { apiBasePath: '/v2' }, { microservice: 'other' }, { queryVersion: 1 }]) { + it(`invalidates a settled result after context changes: ${JSON.stringify(change)}`, async () => { + const pending = deferred(); + const args = { projectId: 'A' }; + fetch.mockResolvedValueOnce(response({ name: 'Old' })).mockReturnValueOnce(pending.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view(args)); }); + await act(async () => { mounted.rerender(view(args, { ...context, ...change })); }); + expect(screen.queryByText('Old')).toBeNull(); + expect(screen.getByText('Loading')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + await act(async () => { pending.resolve(response({ name: 'New' })); }); + expect(screen.getByText('New')).not.toBeNull(); + }); + } + + for (const data of [{ name: {} }, { name: [] }, {}, Object.create({ name: 'Inherited' }), [{ name: 'A' }]]) { + it(`rejects non-scalar or absent own fields: ${JSON.stringify(data)}`, async () => { + fetch.mockResolvedValue(response(data)); + await act(async () => { render(view()); }); + expect(await screen.findByRole('alert')).not.toBeNull(); + }); + } +}); diff --git a/Source/JavaScript/components/data/for_SceneSingleResult/when_gating_the_lazy_runtime.tsx b/Source/JavaScript/components/data/for_SceneSingleResult/when_gating_the_lazy_runtime.tsx new file mode 100644 index 00000000..44b21c7f --- /dev/null +++ b/Source/JavaScript/components/data/for_SceneSingleResult/when_gating_the_lazy_runtime.tsx @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, cleanup, render, screen } from '@testing-library/react'; +import { expect, vi } from 'vitest'; +import { externalComponent } from '../../given'; + +class RegisteredQuery {} + +describe('when gating the single result lazy runtime', () => { + const loaded = vi.fn(); + let SceneSingleResult: typeof import('../SceneSingleResult').SceneSingleResult; + let bindings: typeof import('../../bindings'); + + beforeEach(async () => { + vi.resetModules(); + loaded.mockClear(); + vi.doMock('../SingleResultRuntime', () => { + loaded(); + return { default: () => Runtime mounted }; + }); + bindings = await import('../../bindings'); + bindings.clearBindings(); + bindings.registerQuery('Registered', RegisteredQuery); + ({ SceneSingleResult } = await import('../SceneSingleResult')); + }); + + afterEach(() => { + cleanup(); + bindings.clearBindings(); + vi.doUnmock('../SingleResultRuntime'); + vi.resetModules(); + }); + + for (const properties of [ + { query: 'Missing', enabled: true, queryArguments: {}, resultField: 'name' }, + { query: 'Registered', queryArguments: {}, resultField: 'name' }, + { query: 'Registered', enabled: true, queryArguments: [], resultField: 'name' }, + { query: 'Registered', enabled: true, queryArguments: {}, resultField: '' }, + ]) { + it(`does not import Arc-bound code for ${JSON.stringify(properties)}`, async () => { + await act(async () => { + render(); + }); + expect(loaded).not.toHaveBeenCalled(); + expect(screen.queryByText('Runtime mounted')).toBeNull(); + expect(document.body.textContent).toMatch(/Unresolved query binding|Idle|Unable to load result/); + }); + } + + it('loads only the explicitly bound and enabled view', async () => { + await act(async () => { + render(); + }); + expect(await screen.findByText('Runtime mounted')).not.toBeNull(); + expect(loaded).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Source/JavaScript/components/data/index.ts b/Source/JavaScript/components/data/index.ts index fef4ecaa..c292c2fa 100644 --- a/Source/JavaScript/components/data/index.ts +++ b/Source/JavaScript/components/data/index.ts @@ -1,5 +1,6 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +export * from './SceneSingleResult'; export * from './SceneDataTable'; export * from './SceneObservableDataTable'; diff --git a/Source/JavaScript/components/for_cratisComponentsPackage/when_describing_single_result.ts b/Source/JavaScript/components/for_cratisComponentsPackage/when_describing_single_result.ts new file mode 100644 index 00000000..7243ddd3 --- /dev/null +++ b/Source/JavaScript/components/for_cratisComponentsPackage/when_describing_single_result.ts @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { expect } from 'vitest'; +import { componentRegistryKey } from '@cratis/scene.react'; +import { cratisComponents, cratisComponentsPackageName } from '../cratisComponents'; +import { cratisComponentsPackageManifest } from '../cratisComponentsPackage'; +import { SceneSingleResult } from '../data/SceneSingleResult'; + +describe('when describing the single result view', () => { + it('registers the same component advertised by the manifest', () => { + expect(cratisComponentsPackageManifest.components).toContain('singleResult'); + expect(cratisComponents[componentRegistryKey(cratisComponentsPackageName, 'singleResult')]).toBe(SceneSingleResult); + }); + + it('describes an explicitly enabled read-only own-field capability', () => { + expect(cratisComponentsPackageManifest.description).toContain('read-only scalar own resultField'); + expect(cratisComponentsPackageManifest.description).toContain('enabled: true'); + }); +}); diff --git a/Source/JavaScript/components/package.json b/Source/JavaScript/components/package.json index 6cb292d0..a6f179e5 100644 --- a/Source/JavaScript/components/package.json +++ b/Source/JavaScript/components/package.json @@ -58,6 +58,8 @@ "@cratis/scene.react": "1.0.0" }, "devDependencies": { + "@cratis/arc": "22.16.1", + "@cratis/arc.react": "22.16.1", "@cratis/components": "^4.6.0", "@cratis/scene.engine": "1.0.0", "@cratis/scene.model": "1.0.0", @@ -71,8 +73,18 @@ "storybook": "^10.6.0" }, "peerDependencies": { + "@cratis/arc": "^22.16.1", + "@cratis/arc.react": "^22.16.1", "@cratis/components": "^4.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" + }, + "peerDependenciesMeta": { + "@cratis/arc": { + "optional": true + }, + "@cratis/arc.react": { + "optional": true + } } } From c6806b844a5ba9c73321826528eefd4ec21ab400 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 16 Sep 2026 19:59:47 +0200 Subject: [PATCH 2/2] Bridge Scene bindings through typed native Arc adapters Keep Arc loading lazy while validating generated proxy kinds at one typed boundary. Supply the native radio group name from the explicit property without changing the exported FieldBinding contract. Fresh forced type checks, 186 component tests, lint and bundle build pass; independent bridge review approved the scoped native-proxy contract. Fresh CI must also cover its newer Components resolution. --- .../bindings/ArcBoundComponents.tsx | 40 +++++++++++ .../components/bindings/BoundConstructor.ts | 10 +-- .../for_requireArcProxy/NativeProxies.ts | 28 ++++++++ .../when_mounting_bound_adapters.tsx | 71 +++++++++++++++++++ .../when_previewing_without_arc.tsx | 37 ++++++++++ .../when_validating_a_proxy.ts | 44 ++++++++++++ .../components/bindings/requireArcProxy.ts | 24 +++++++ .../components/data/SceneDataTable.tsx | 2 +- .../data/SceneObservableDataTable.tsx | 2 +- .../when_forwarding_query_arguments.tsx | 6 +- .../components/dialogs/SceneCommandDialog.tsx | 2 +- .../dialogs/SceneStepperCommandDialog.tsx | 2 +- .../forms/fields/SceneRadioButtonField.tsx | 5 +- .../when_mapping_the_group_name.tsx | 45 ++++++++++++ .../when_resolving_a_field_binding.ts | 6 ++ .../components/pages/SceneDataPage.tsx | 2 +- 16 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 Source/JavaScript/components/bindings/ArcBoundComponents.tsx create mode 100644 Source/JavaScript/components/bindings/for_requireArcProxy/NativeProxies.ts create mode 100644 Source/JavaScript/components/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx create mode 100644 Source/JavaScript/components/bindings/for_requireArcProxy/when_previewing_without_arc.tsx create mode 100644 Source/JavaScript/components/bindings/for_requireArcProxy/when_validating_a_proxy.ts create mode 100644 Source/JavaScript/components/bindings/requireArcProxy.ts create mode 100644 Source/JavaScript/components/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.tsx diff --git a/Source/JavaScript/components/bindings/ArcBoundComponents.tsx b/Source/JavaScript/components/bindings/ArcBoundComponents.tsx new file mode 100644 index 00000000..859ff1c7 --- /dev/null +++ b/Source/JavaScript/components/bindings/ArcBoundComponents.tsx @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// Runtime-only module: import dynamically beneath ArcRuntimeBoundary, never through the barrel. +import type { ComponentProps } from 'react'; +import { QueryFor, ObservableQueryFor } from '@cratis/arc/queries'; +import { Command } from '@cratis/arc/commands'; +import { DataTableForQuery, DataTableForObservableQuery } from '@cratis/components/DataTables'; +import { DataPage } from '@cratis/components/DataPage'; +import { CommandDialog, StepperCommandDialog } from '@cratis/components/CommandDialog'; +import type { BoundConstructor } from './BoundConstructor'; +import { requireArcProxy } from './requireArcProxy'; + +type QueryProps = Omit & { query: BoundConstructor }; +type CommandProps = Omit & { command: BoundConstructor }; + +// Scene carries property names and argument objects, not compile-time row schemas. These adapters +// support generated object/collection queries, as required by the Components public prop contracts. +export function BoundDataTable({ query, ...props }: QueryProps>) { + return , 'query')} />; +} + +export function BoundObservableDataTable({ query, ...props }: QueryProps>) { + return , 'observable query')} />; +} + +export function BoundDataPage({ query, ...props }: QueryProps>) { + const base = query.prototype instanceof ObservableQueryFor ? ObservableQueryFor : QueryFor; + // This chooses only the kind of the supplied constructor, never another registered binding. + const proxy = requireArcProxy | ObservableQueryFor>(query, base, 'query'); + return ; +} + +export function BoundCommandDialog({ command, ...props }: CommandProps>) { + return , 'command')} />; +} + +export function BoundStepperCommandDialog({ command, ...props }: CommandProps>) { + return , 'command')} />; +} diff --git a/Source/JavaScript/components/bindings/BoundConstructor.ts b/Source/JavaScript/components/bindings/BoundConstructor.ts index e97c1c8c..3d6cb592 100644 --- a/Source/JavaScript/components/bindings/BoundConstructor.ts +++ b/Source/JavaScript/components/bindings/BoundConstructor.ts @@ -6,14 +6,14 @@ * `[ReadModel]` and `[Command]` types. * * `@cratis/components` types these as `Constructor` from `@cratis/fundamentals`, constrained to - * `IQueryFor` / a command shape. This package cannot use those types: `@cratis/arc` and - * `@cratis/fundamentals` are peer dependencies of `@cratis/components` that the *host* provides, and - * Scene deliberately does not depend on Arc - a Scene screen is a UI model, and the whole point of the - * registry is that Scene never needs to know what an Arc proxy is. + * `IQueryFor` / a command shape. This registry intentionally does not use those types: optional + * Arc peers must not make an Arc-free preview load Arc. The lazy Components bridge validates native + * proxy inheritance and the zero-argument constructor convention before passing a binding on. * * So the registry stores the widest honest shape: something that can be constructed. Whether a * registered class really is a query or a command is the host's responsibility, and it is checkable - * where the host registers it, with the real Arc types in scope - which is exactly where it should be. + * where the host registers it, with the real Arc types in scope. Arbitrary registrations remain usable + * by other consumers; the lazy Arc bridge rejects unsupported constructors visibly. * * `never[]` rather than `unknown[]` for the parameters so that any constructor is assignable, regardless * of what it takes; `unknown[]` would only accept constructors whose parameters accept anything. diff --git a/Source/JavaScript/components/bindings/for_requireArcProxy/NativeProxies.ts b/Source/JavaScript/components/bindings/for_requireArcProxy/NativeProxies.ts new file mode 100644 index 00000000..eee27403 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_requireArcProxy/NativeProxies.ts @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { QueryFor, ObservableQueryFor } from '@cratis/arc/queries'; +import { Command } from '@cratis/arc/commands'; + +export class Snapshot extends QueryFor { + readonly route = '/projects'; + readonly parameterDescriptors = []; + readonly requiredRequestParameters = ['projectId']; + defaultValue = []; + constructor() { super(Object, true); throw new Error('Must not construct during binding'); } +} + +export class Live extends ObservableQueryFor { + readonly route = '/projects'; + readonly parameterDescriptors = []; + readonly requiredRequestParameters = []; + readonly defaultValue = []; + constructor() { super(Object, true); throw new Error('Must not construct during binding'); } +} + +export class Save extends Command { + readonly route = '/save'; + readonly propertyDescriptors = []; + readonly requestParameters = []; + constructor() { super(Object, false); throw new Error('Must not construct during binding'); } +} diff --git a/Source/JavaScript/components/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx b/Source/JavaScript/components/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx new file mode 100644 index 00000000..6759716c --- /dev/null +++ b/Source/JavaScript/components/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx @@ -0,0 +1,71 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, cleanup, render, screen } from '@testing-library/react'; +import { expect, vi } from 'vitest'; +import { externalComponent } from '../../given'; + +// Keep the real Arc base classes; replace only the final Components renderers. +describe('when mounting bound adapters', () => { + const received = vi.fn<(props: Record) => void>(); + let bindings: typeof import('../bindingRegistry'); + let proxies: typeof import('./NativeProxies'); + let adapters: typeof import('../ArcBoundComponents'); + let Boundary: typeof import('../ArcRuntimeBoundary').ArcRuntimeBoundary; + + beforeEach(async () => { + vi.resetModules(); + received.mockClear(); + const component = (props: Record) => { received(props); return Mounted native adapter; }; + vi.doMock('@cratis/components/DataTables', () => ({ DataTableForQuery: component, DataTableForObservableQuery: component })); + vi.doMock('@cratis/components/DataPage', () => ({ DataPage: component })); + vi.doMock('@cratis/components/CommandDialog', () => ({ CommandDialog: component, StepperCommandDialog: component })); + bindings = await import('../bindingRegistry'); + bindings.clearBindings(); + proxies = await import('./NativeProxies'); + adapters = await import('../ArcBoundComponents'); + ({ ArcRuntimeBoundary: Boundary } = await import('../ArcRuntimeBoundary')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + cleanup(); + bindings.clearBindings(); + vi.restoreAllMocks(); + vi.doUnmock('@cratis/components/DataTables'); + vi.doUnmock('@cratis/components/DataPage'); + vi.doUnmock('@cratis/components/CommandDialog'); + vi.resetModules(); + }); + + it('should pass both supported page kinds and both command dialog constructors unchanged', () => { + const { BoundDataPage, BoundObservableDataTable, BoundCommandDialog, BoundStepperCommandDialog } = adapters; + render(<> + + + + ); + expect(received.mock.calls.map(([props]) => props.query ?? props.command)).toEqual([proxies.Snapshot, proxies.Live, proxies.Live, proxies.Save, proxies.Save]); + }); + + it('should visibly reject a wrong-kind registered query without selecting another query', async () => { + bindings.registerQuery('wrong', proxies.Save); + bindings.registerQuery('valid', proxies.Snapshot); + const { SceneDataTable } = await import('../../data/SceneDataTable'); + await act(async () => { + render(); + }); + expect(screen.getByText("Invalid query binding 'Save': expected a zero-argument Arc query proxy")).toBeTruthy(); + expect(received).not.toHaveBeenCalled(); + }); + + it('should visibly reject snapshot bindings in observable tables and query bindings in dialogs', () => { + const { BoundObservableDataTable, BoundCommandDialog, BoundStepperCommandDialog, BoundDataPage } = adapters; + render(<> + + + ); + expect(screen.getAllByText('Error')).toHaveLength(4); + expect(received).not.toHaveBeenCalled(); + }); +}); diff --git a/Source/JavaScript/components/bindings/for_requireArcProxy/when_previewing_without_arc.tsx b/Source/JavaScript/components/bindings/for_requireArcProxy/when_previewing_without_arc.tsx new file mode 100644 index 00000000..32791272 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_requireArcProxy/when_previewing_without_arc.tsx @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, cleanup, render, screen } from '@testing-library/react'; +import { expect, vi } from 'vitest'; +import { externalComponent } from '../../given'; + +describe('when previewing bound adapters without Arc', () => { + const loaded = vi.fn(); + + afterEach(() => { + cleanup(); + vi.doUnmock('../ArcBoundComponents'); + vi.resetModules(); + }); + + it('should show missing bindings without importing the Arc runtime module', async () => { + vi.resetModules(); + vi.doMock('../ArcBoundComponents', () => { + loaded(); + throw new Error('Arc is unavailable in preview'); + }); + const { clearBindings } = await import('../bindingRegistry'); + clearBindings(); + const { SceneDataTable } = await import('../../data/SceneDataTable'); + const { SceneObservableDataTable } = await import('../../data/SceneObservableDataTable'); + const { SceneDataPage } = await import('../../pages/SceneDataPage'); + const { SceneCommandDialog } = await import('../../dialogs/SceneCommandDialog'); + const { SceneStepperCommandDialog } = await import('../../dialogs/SceneStepperCommandDialog'); + await act(async () => { + render(<>{[SceneDataTable, SceneObservableDataTable, SceneDataPage, SceneCommandDialog, SceneStepperCommandDialog].map((Adapter, index) => + )}); + }); + expect(screen.getAllByText(/Unresolved .* binding 'missing'/)).toHaveLength(5); + expect(loaded).not.toHaveBeenCalled(); + }); +}); diff --git a/Source/JavaScript/components/bindings/for_requireArcProxy/when_validating_a_proxy.ts b/Source/JavaScript/components/bindings/for_requireArcProxy/when_validating_a_proxy.ts new file mode 100644 index 00000000..e7035637 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_requireArcProxy/when_validating_a_proxy.ts @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { expect } from 'vitest'; +import { QueryFor, ObservableQueryFor } from '@cratis/arc/queries'; +import { Command } from '@cratis/arc/commands'; +import type { ComponentProps } from 'react'; +import type { DataTableForQuery, DataTableForObservableQuery } from '@cratis/components/DataTables'; +import type { CommandDialog } from '@cratis/components/CommandDialog'; +import { requireArcProxy } from '../requireArcProxy'; +import { registerQuery, resolveQuery, clearBindings } from '../bindingRegistry'; +import { Snapshot, Live, Save } from './NativeProxies'; + +describe('when validating a proxy at the lazy Arc boundary', () => { + afterEach(clearBindings); + + it('should preserve native constructors without constructing them and satisfy public component props', () => { + const query: ComponentProps['query'] = requireArcProxy(Snapshot, QueryFor, 'query'); + const observable: ComponentProps['query'] = requireArcProxy(Live, ObservableQueryFor, 'observable query'); + const command: ComponentProps['command'] = requireArcProxy(Save, Command, 'command'); + expect(query).toBe(Snapshot); + expect(observable).toBe(Live); + expect(command).toBe(Save); + }); + + it('should reject wrong kinds rather than perform an observable query as a snapshot', () => { + expect(() => requireArcProxy(Live, QueryFor, 'query')).toThrow('Invalid query binding'); + expect(() => requireArcProxy(Snapshot, ObservableQueryFor, 'observable query')).toThrow('Invalid observable query binding'); + expect(() => requireArcProxy(Snapshot, Command, 'command')).toThrow('Invalid command binding'); + expect(() => requireArcProxy(Save, QueryFor, 'query')).toThrow('Invalid query binding'); + }); + + it('should preserve arbitrary constructor registration but reject it at an Arc adapter', () => { + class Arbitrary { constructor(readonly input: string) {} } + registerQuery('arbitrary', Arbitrary); + expect(resolveQuery('arbitrary')).toBe(Arbitrary); + expect(() => requireArcProxy(Arbitrary, QueryFor, 'query')).toThrow('Invalid query binding'); + }); + + it('should reject a native subclass requiring constructor arguments without invoking it', () => { + class NeedsArguments extends Snapshot { constructor(readonly input: string) { super(); } } + expect(() => requireArcProxy(NeedsArguments, QueryFor, 'query')).toThrow('zero-argument Arc'); + }); +}); diff --git a/Source/JavaScript/components/bindings/requireArcProxy.ts b/Source/JavaScript/components/bindings/requireArcProxy.ts new file mode 100644 index 00000000..d426249d --- /dev/null +++ b/Source/JavaScript/components/bindings/requireArcProxy.ts @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { BoundConstructor } from './BoundConstructor'; + +/** + * Bridge for the generated, zero-argument Arc proxies consumed by Components. The registry remains + * open to arbitrary constructors; only mounting an Arc adapter requires this narrower contract. + * Check inheritance without constructing a proxy (constructors can have side effects). + * + * This is not structural interface validation: the host still owns the generated subclass's data, + * argument and implementation correctness. Prototype identity deliberately rejects duck-typed proxies + * and proxies from a second Arc installation. The arity check enforces the generated convention, + * not general JavaScript constructor safety (defaults/rest parameters can hide requirements). + * The single assertion bridges the registry under that host-owned generated-proxy contract; it is + * not a proof of arbitrary subclass behavior or of erased row/argument types. + */ +export function requireArcProxy(target: BoundConstructor, base: { prototype: T }, kind: string): new () => T { + const prototype: unknown = target.prototype; + if (target.length !== 0 || typeof prototype !== 'object' || prototype === null || !Object.prototype.isPrototypeOf.call(base.prototype, prototype)) { + throw new Error(`Invalid ${kind} binding '${target.name}': expected a zero-argument Arc ${kind} proxy`); + } + return target as new () => T; +} diff --git a/Source/JavaScript/components/data/SceneDataTable.tsx b/Source/JavaScript/components/data/SceneDataTable.tsx index 226eb13f..776d8a76 100644 --- a/Source/JavaScript/components/data/SceneDataTable.tsx +++ b/Source/JavaScript/components/data/SceneDataTable.tsx @@ -7,7 +7,7 @@ import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding import { objectProperty, stringArrayProperty, stringProperty } from '../properties'; import { Placeholder } from '../bindings/Placeholder'; -const DataTableForQuery = lazy(async () => ({ default: (await import('@cratis/components/DataTables')).DataTableForQuery })); +const DataTableForQuery = lazy(async () => ({ default: (await import('../bindings/ArcBoundComponents')).BoundDataTable })); /** * The `Cratis.Components:dataTable` component, also registered as `table` - `DataTableForQuery` from diff --git a/Source/JavaScript/components/data/SceneObservableDataTable.tsx b/Source/JavaScript/components/data/SceneObservableDataTable.tsx index 3bce1b07..0d81029e 100644 --- a/Source/JavaScript/components/data/SceneObservableDataTable.tsx +++ b/Source/JavaScript/components/data/SceneObservableDataTable.tsx @@ -7,7 +7,7 @@ import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding import { stringArrayProperty, stringProperty } from '../properties'; const DataTableForObservableQuery = lazy(async () => ({ - default: (await import('@cratis/components/DataTables')).DataTableForObservableQuery, + default: (await import('../bindings/ArcBoundComponents')).BoundObservableDataTable, })); /** diff --git a/Source/JavaScript/components/data/for_SceneDataTable/when_forwarding_query_arguments.tsx b/Source/JavaScript/components/data/for_SceneDataTable/when_forwarding_query_arguments.tsx index 8dbbbc17..43c24ffa 100644 --- a/Source/JavaScript/components/data/for_SceneDataTable/when_forwarding_query_arguments.tsx +++ b/Source/JavaScript/components/data/for_SceneDataTable/when_forwarding_query_arguments.tsx @@ -5,8 +5,8 @@ import { act, cleanup, render, screen } from '@testing-library/react'; import { vi } from 'vitest'; import { externalComponent } from '../../given'; -class InvoicesForProject {} -class AnotherQuery {} +let InvoicesForProject: typeof import('../../bindings/for_requireArcProxy/NativeProxies').Snapshot; +let AnotherQuery: typeof InvoicesForProject; // Load a fresh adapter so its lazy component cannot leak the peer double into other specs. describe('when forwarding query arguments', () => { @@ -24,6 +24,8 @@ describe('when forwarding query arguments', () => { return
; }, })); + ({ Snapshot: InvoicesForProject } = await import('../../bindings/for_requireArcProxy/NativeProxies')); + AnotherQuery = InvoicesForProject; bindings = await import('../../bindings'); bindings.clearBindings(); bindings.registerQuery('InvoicesForProject', InvoicesForProject); diff --git a/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx b/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx index 7d888d63..c63f1c83 100644 --- a/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx +++ b/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx @@ -6,7 +6,7 @@ import { RegisteredComponentProps } from '@cratis/scene.react'; import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; import { booleanProperty, stringProperty } from '../properties'; -const CommandDialog = lazy(async () => ({ default: (await import('@cratis/components/CommandDialog')).CommandDialog })); +const CommandDialog = lazy(async () => ({ default: (await import('../bindings/ArcBoundComponents')).BoundCommandDialog })); /** * The `Cratis.Components:commandDialog` component - `CommandDialog` from `@cratis/components/CommandDialog`. diff --git a/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx b/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx index dccee755..3b2f70a5 100644 --- a/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx +++ b/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx @@ -7,7 +7,7 @@ import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding import { booleanProperty, stringProperty, unionProperty } from '../properties'; const StepperCommandDialog = lazy(async () => ({ - default: (await import('@cratis/components/CommandDialog')).StepperCommandDialog, + default: (await import('../bindings/ArcBoundComponents')).BoundStepperCommandDialog, })); /** Whether the step headers run across the top or down the side. */ diff --git a/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx b/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx index fb0500f8..7db7ce0c 100644 --- a/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx +++ b/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx @@ -20,13 +20,16 @@ const RadioButtonField = lazy(async () => ({ default: (await import('@cratis/com * numeric code is common enough that forcing it through as `'1'` would bind the wrong value. */ export function SceneRadioButtonField({ element }: RegisteredComponentProps) { + // Use the same reader as CommandFormField's binding gate; that gate owns missing-property feedback. + const name = stringProperty(element.properties, 'property'); const buttonValue = stringProperty(element.properties, 'buttonValue') ?? numberProperty(element.properties, 'buttonValue') ?? ''; return ( - {binding => ( + {binding => name !== undefined && ( { + const received = vi.fn<(props: { name: string; title: string; value: (instance: Record) => unknown }) => void>(); + let SceneRadioButtonField: typeof import('../SceneRadioButtonField').SceneRadioButtonField; + + beforeEach(async () => { + vi.resetModules(); + received.mockClear(); + vi.doMock('@cratis/components/CommandForm', () => ({ + RadioButtonField: (props: Parameters[0]) => { received(props); return ; }, + })); + ({ SceneRadioButtonField } = await import('../SceneRadioButtonField')); + }); + + afterEach(() => { + cleanup(); + vi.doUnmock('@cratis/components/CommandForm'); + vi.resetModules(); + }); + + it('should group options by the explicit property, regardless of titles or labels', async () => { + await act(async () => { + render(<>{['First title', 'Second title'].map((title, index) => )}); + }); + expect(screen.getAllByRole('radio').map(radio => radio.getAttribute('name'))).toEqual(['choice', 'choice']); + expect(received.mock.calls.map(([props]) => props.value({ choice: 2 }))).toEqual([2, 2]); + }); + + for (const property of [undefined, 42, null, {}, []]) { + it(`should visibly reject malformed property ${JSON.stringify(property)} instead of using the title`, async () => { + await act(async () => { + render(); + }); + expect(screen.getByText("Missing 'property' on Cratis.Components:radioButtonField")).toBeTruthy(); + expect(received).not.toHaveBeenCalled(); + }); + } +}); diff --git a/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts b/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts index 75af7a84..e75c320c 100644 --- a/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts +++ b/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts @@ -3,8 +3,14 @@ import { externalComponent } from '../../../given'; import { resolveFieldBinding } from '../resolveFieldBinding'; +import type { FieldBinding } from '../FieldBinding'; describe('when resolving a field binding', () => { + it('should preserve manually constructed field bindings without a native group name', () => { + const binding: FieldBinding = { value: instance => instance.customerName, title: 'Customer' }; + binding.value({ customerName: 'Acme' })!.should.equal('Acme'); + }); + describe('and the element names a property with a title and description', () => { const binding = resolveFieldBinding( externalComponent('Cratis.Components:inputTextField', { diff --git a/Source/JavaScript/components/pages/SceneDataPage.tsx b/Source/JavaScript/components/pages/SceneDataPage.tsx index 300c59e6..4196346b 100644 --- a/Source/JavaScript/components/pages/SceneDataPage.tsx +++ b/Source/JavaScript/components/pages/SceneDataPage.tsx @@ -6,7 +6,7 @@ import { RegisteredComponentProps } from '@cratis/scene.react'; import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; import { stringArrayProperty, stringProperty } from '../properties'; -const DataPage = lazy(async () => ({ default: (await import('@cratis/components/DataPage')).DataPage })); +const DataPage = lazy(async () => ({ default: (await import('../bindings/ArcBoundComponents')).BoundDataPage })); /** * The `Cratis.Components:dataPage` component - `DataPage` from `@cratis/components/DataPage`.