diff --git a/README.md b/README.md index c053c67..6f8f9a2 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ This is opt-in and local to the component author: - it does not change `defineComponent(...)` - it validates only the keys you list -- it throws immediately on the first invalid prop +- it follows `config.propValidationMode` - it does not coerce values - it does not mutate `head.props` @@ -156,6 +156,7 @@ pval.oneOf(['create', 'edit'] as const) pval.arrayOf(pval.isString) pval.shape({ title: pval.isString, count: pval.isNumber }) pval.refOf(pval.isString) +pval.fail('title', 'expected non-empty string') ``` ### Dynamic bindings and refs @@ -199,11 +200,11 @@ head.validateProps({ Users can provide their own validators as long as they match the `PropValidator` signature: ```ts -import { type PropValidator } from 'regor' +import { pval, type PropValidator } from 'regor' const isNonEmptyString: PropValidator = (value, name) => { if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`Invalid prop "${name}": expected non-empty string.`) + pval.fail(name, 'expected non-empty string') } } @@ -218,11 +219,34 @@ Custom validators can also use the third `head` argument: const startsWithPrefix: PropValidator = (value, name, head) => { const ctx = head.requireContext(AppServices) if (typeof value !== 'string' || !value.startsWith(ctx.prefix)) { - throw new Error(`Invalid prop "${name}": expected prefixed value.`) + pval.fail(name, 'expected prefixed value') } } ``` +### Validation mode + +Validation behavior is controlled through `RegorConfig.propValidationMode`: + +```ts +import { RegorConfig } from 'regor' + +const config = new RegorConfig() +config.propValidationMode = 'warn' +``` + +Available modes: + +- `'throw'` (default): throw immediately on invalid prop +- `'warn'`: report through `warningHandler.warning(...)` and continue +- `'off'`: skip runtime prop validation entirely + +Pass the config into `createApp(...)` when you want app-level control: + +```ts +createApp(appContext, template, config) +``` + ## Table Templates and Components Regor preprocesses table-related templates to keep markup valid when using diff --git a/docs-site/src/content/docs/api/createApp.md b/docs-site/src/content/docs/api/createApp.md index c39c327..13f928d 100644 --- a/docs-site/src/content/docs/api/createApp.md +++ b/docs-site/src/content/docs/api/createApp.md @@ -23,6 +23,7 @@ To create a Regor application, call the `createApp` function with the following - `isSVG` (boolean, optional): Indicates whether the template contains SVG elements. - `config` (optional): An optional configuration object for customizing Regor's behavior. + - `config.propValidationMode`: Controls whether component prop validation throws, warns, or is disabled. ### Example @@ -45,6 +46,17 @@ const app = createApp(appContext) // default template: { selector: '#app'} - `config` (optional): An optional configuration object that customizes Regor's behavior. It allows you to specify various options. +Example: + +```ts +import { createApp, RegorConfig } from 'regor' + +const config = new RegorConfig() +config.propValidationMode = 'warn' + +createApp(appContext, template, config) +``` + ## Return Value The `createApp` function returns an object with the following properties: @@ -68,12 +80,15 @@ The `createApp` function returns an object with the following properties: `tbody`, and `tfoot`. - The `config` parameter lets you customize Regor's behavior to suit your application's requirements. +- `config.propValidationMode` controls how `head.validateProps(...)` behaves for components created under that app. - You can use the `unmount` and `unbind` functions to clean up and remove the app from the DOM when it's no longer needed. ## See Also - [`defineComponent`](/api/defineComponent) +- [`RegorConfig`](/api/regorConfig) +- [`pval`](/api/pval) - [`toFragment`](/api/toFragment) - [`toJsonTemplate`](/api/toJsonTemplate) diff --git a/docs-site/src/content/docs/api/defineComponent.md b/docs-site/src/content/docs/api/defineComponent.md index 365fdb9..445f3e6 100644 --- a/docs-site/src/content/docs/api/defineComponent.md +++ b/docs-site/src/content/docs/api/defineComponent.md @@ -91,7 +91,7 @@ const Card = defineComponent(html`
{{ summary }}
`, { Behavior: - validates only the listed keys -- throws on first invalid prop +- follows `config.propValidationMode` - does not mutate `head.props` - does not coerce values @@ -122,6 +122,16 @@ const Card = defineComponent(html`
{{ summary }}
`, { For object-style `:context="{ ... }"` values, validate the object shape directly with `pval.shape(...)`. +### Validation mode + +`head.validateProps(...)` follows the active `RegorConfig.propValidationMode`: + +- `'throw'` (default): throw on invalid prop +- `'warn'`: warn and continue +- `'off'`: disable runtime validation + +Set it on the config passed to `createApp(...)`. + ## Return Value The `defineComponent` function returns a component object with the following properties: diff --git a/docs-site/src/content/docs/api/pval.md b/docs-site/src/content/docs/api/pval.md index e3d4b6f..b8b5be1 100644 --- a/docs-site/src/content/docs/api/pval.md +++ b/docs-site/src/content/docs/api/pval.md @@ -30,7 +30,24 @@ Validation is: 2. local to the component 3. runtime-only 4. non-coercive -5. fail-fast +5. controlled by `RegorConfig.propValidationMode` + +## Validation mode + +Runtime prop-validation behavior is controlled through `RegorConfig.propValidationMode`: + +```ts +import { RegorConfig } from 'regor' + +const config = new RegorConfig() +config.propValidationMode = 'warn' +``` + +Modes: + +1. `'throw'` (default): invalid props throw immediately. +2. `'warn'`: invalid props are reported through `warningHandler.warning(...)`. +3. `'off'`: runtime prop validation is skipped. ## Built-in validators @@ -153,6 +170,22 @@ head.validateProps({ }) ``` +### `pval.fail(name, detail)` + +Throws the same structured validation failure used by Regor's built-in validators. + +This is the recommended way for custom validators to report invalid input, +because it preserves nested prop paths and lets `head.validateProps(...)` +format the final component-aware error consistently. + +```ts +const isNonEmptyString: PropValidator = (value, name) => { + if (typeof value !== 'string' || value.trim() === '') { + pval.fail(name, 'expected non-empty string') + } +} +``` + ## Dynamic props vs object props Single-prop bindings like `:title="titleRef"` may arrive as refs at runtime, so `pval.refOf(...)` is appropriate there. @@ -164,11 +197,11 @@ Object-style `:context="{ meta: { slug: 'x' } }"` values can be validated direct You are not limited to `pval`. Users can write any validator that matches `PropValidator`: ```ts -import { type PropValidator } from 'regor' +import { pval, type PropValidator } from 'regor' const isNonEmptyString: PropValidator = (value, name) => { if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`Invalid prop "${name}": expected non-empty string.`) + pval.fail(name, 'expected non-empty string') } } ``` @@ -179,7 +212,7 @@ Custom validators also receive `head` as the third argument: const startsWithPrefix: PropValidator = (value, name, head) => { const services = head.requireContext(AppServices) if (typeof value !== 'string' || !value.startsWith(services.prefix)) { - throw new Error(`Invalid prop "${name}": expected prefixed value.`) + pval.fail(name, 'expected prefixed value') } } ``` diff --git a/docs-site/src/content/docs/api/regorConfig.md b/docs-site/src/content/docs/api/regorConfig.md index 884d500..867df52 100644 --- a/docs-site/src/content/docs/api/regorConfig.md +++ b/docs-site/src/content/docs/api/regorConfig.md @@ -48,6 +48,27 @@ This property holds the global context object, which can be used to define globa This property controls whether Regor should enable interpolation for data binding. When set to `true`, Regor will automatically perform data binding using the `{{ }}` syntax in templates. Interpolation also supports the `[[ ]]` syntax. +### `propValidationMode` (`'throw' | 'warn' | 'off'`) + +- Default: `'throw'` + +Controls how `head.validateProps(...)` behaves when a validator fails. + +- `'throw'`: throw immediately on invalid prop +- `'warn'`: forward the validation failure to `warningHandler.warning(...)` and continue +- `'off'`: disable runtime prop validation + +Example: + +```ts +import { RegorConfig, createApp } from 'regor' + +const config = new RegorConfig() +config.propValidationMode = 'warn' + +createApp(appContext, template, config) +``` + ## Methods ### `addComponent(...)` @@ -122,6 +143,7 @@ The `RegorConfig` class provides default configuration values for various proper - The `useInterpolation` property enables or disables the use of `{{}}` syntax for data binding in templates. - Interpolation supports both `{{ }}` and `[[ ]]` syntaxes. +- The `propValidationMode` property controls whether component prop validation throws, warns, or is disabled. - Registering custom components using `addComponent` makes those components available for use in your Regor templates. @@ -133,6 +155,7 @@ The `RegorConfig` class provides default configuration values for various proper - [`createApp`](/api/createApp) - [`defineComponent`](/api/defineComponent) +- [`pval`](/api/pval) - [`toFragment`](/api/toFragment) - [`toJsonTemplate`](/api/toJsonTemplate) diff --git a/docs-site/src/content/docs/guide/components.md b/docs-site/src/content/docs/guide/components.md index d5c244a..1d27385 100644 --- a/docs-site/src/content/docs/guide/components.md +++ b/docs-site/src/content/docs/guide/components.md @@ -113,7 +113,7 @@ This validation model is intentionally runtime-first: 1. It is fully opt-in. 2. It validates only the keys you list. -3. It throws immediately on the first invalid prop. +3. It follows the active `propValidationMode` config. 4. It does not coerce values. 5. It does not mutate `head.props`. @@ -163,6 +163,7 @@ const EditorCard = defineComponent( 8. `pval.arrayOf(validator)` 9. `pval.shape({ ... })` 10. `pval.refOf(validator)` +11. `pval.fail(name, detail)` Example: @@ -225,11 +226,11 @@ head.validateProps({ Users are not limited to the built-in validators. Any function matching `PropValidator` can be used: ```ts -import { type PropValidator } from 'regor' +import { pval, type PropValidator } from 'regor' const isNonEmptyString: PropValidator = (value, name) => { if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`Invalid prop "${name}": expected non-empty string.`) + pval.fail(name, 'expected non-empty string') } } @@ -244,7 +245,7 @@ Custom validators can also use the third `head` argument: const startsWithParentPrefix: PropValidator = (value, name, head) => { const ctx = head.requireContext(AppServices) if (typeof value !== 'string' || !value.startsWith(ctx.prefix)) { - throw new Error(`Invalid prop "${name}": expected prefixed value.`) + pval.fail(name, 'expected prefixed value') } } ``` @@ -258,6 +259,29 @@ Validation is most useful when: 3. You want an explicit runtime guard before local state mapping. 4. You set `head.autoProps = false` and map incoming props manually. +### Validation mode + +Validation behavior is configured through `RegorConfig.propValidationMode`: + +```ts +import { RegorConfig } from 'regor' + +const config = new RegorConfig() +config.propValidationMode = 'warn' +``` + +Modes: + +1. `'throw'` (default): throws on the first invalid prop. +2. `'warn'`: sends the validation failure to `warningHandler.warning(...)` and continues. +3. `'off'`: skips runtime prop validation. + +Pass the config into `createApp(...)` to apply the mode for that app: + +```ts +createApp(appContext, template, config) +``` + ## How Component Inputs Are Routed On a component host tag, Regor routes bindings through two different channels: diff --git a/docs-site/src/content/docs/guide/typescript.md b/docs-site/src/content/docs/guide/typescript.md index 5f23e1b..4cb0f51 100644 --- a/docs-site/src/content/docs/guide/typescript.md +++ b/docs-site/src/content/docs/guide/typescript.md @@ -162,11 +162,11 @@ const TitleCard = defineComponent( You can also write custom validators: ```ts -import { type PropValidator } from 'regor' +import { pval, type PropValidator } from 'regor' const isNonEmptyString: PropValidator = (value, name) => { if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`Invalid prop "${name}": expected non-empty string.`) + pval.fail(name, 'expected non-empty string') } } ``` diff --git a/package.json b/package.json index d2e65b6..45b823f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "regor", - "version": "1.4.6", + "version": "1.4.7", "description": "A modern UI framework for web and desktop applications, inspired by Vue's concepts and powered by simplicity and flexibility.", "author": "Ahmed Yasin Koculu", "license": "MIT", diff --git a/src/app/ComponentHead.ts b/src/app/ComponentHead.ts index 051493d..a37caa3 100644 --- a/src/app/ComponentHead.ts +++ b/src/app/ComponentHead.ts @@ -1,16 +1,50 @@ import { type IRegorContext } from '../api/types' import { removeNode } from '../cleanup/removeNode' import { callUnmounted } from '../composition/callUnmounted' +import { warningHandler } from '../log/warnings' import { type InferPropValidationSchema, + PropValidationError, type PropValidationSchemaFor, type PropValidator, } from './propValidators' +import { type PropValidationMode } from './RegorConfig' export type ContextClass = abstract new ( ...args: never[] ) => TValue +const formatComponentValidationError = ( + element: Element, + propName: string, + error: unknown, +): Error => { + const tagName = element.tagName?.toLowerCase?.() || 'unknown' + const finalPropName = + error instanceof PropValidationError ? error.propPath : propName + const detail = + error instanceof PropValidationError + ? error.detail + : error instanceof Error + ? error.message + : String(error) + + if (error instanceof Error) { + return new Error( + `Invalid prop "${finalPropName}" on <${tagName}>: ${detail}`, + { + cause: error, + }, + ) + } + return new Error( + `Invalid prop "${finalPropName}" on <${tagName}>: ${detail}`, + { + cause: error, + }, + ) +} + /** * Runtime metadata passed to a component's `context(head)` factory. * @@ -136,18 +170,26 @@ export class ComponentHead< */ __element: Element + /** + * Runtime behavior used when `validateProps(...)` encounters invalid input. + * Defaults to `'throw'`. + */ + __propValidationMode: PropValidationMode + constructor( props: TContext, element: Element, ctx: IRegorContext[], start: Comment, end: Comment, + propValidationMode: PropValidationMode, ) { this.props = props this.__element = element this.ctx = ctx this.start = start this.end = end + this.__propValidationMode = propValidationMode } /** @@ -246,7 +288,8 @@ export class ComponentHead< * known prop names while still allowing you to validate only a subset. * * Validators typically come from `pval`, but custom user validators are also - * supported. + * supported. Custom validators may throw their own `Error`, though `pval.fail(...)` + * is recommended so nested validators can preserve the exact failing prop path. * * Example: * ```ts @@ -256,6 +299,15 @@ export class ComponentHead< * }) * ``` * + * Example with a custom validator: + * ```ts + * const isNonEmptyString: PropValidator = (value, name) => { + * if (typeof value !== 'string' || value.trim() === '') { + * pval.fail(name, 'expected non-empty string') + * } + * } + * ``` + * * @param schema - Validators to apply to selected incoming props. */ validateProps>( @@ -263,12 +315,26 @@ export class ComponentHead< ): asserts this is ComponentHead< TContext & InferPropValidationSchema > { + if (this.__propValidationMode === 'off') return const props = this.props as Record for (const name in schema) { const validator: PropValidator | undefined = schema[name] if (!validator) continue const validateProp: PropValidator = validator - validateProp(props[name], name, this) + try { + validateProp(props[name], name, this) + } catch (error) { + const enrichedError = formatComponentValidationError( + this.__element, + name, + error, + ) + if (this.__propValidationMode === 'warn') { + warningHandler.warning(enrichedError.message, enrichedError) + continue + } + throw enrichedError + } } } diff --git a/src/app/RegorConfig.ts b/src/app/RegorConfig.ts index 052094f..bc6b7d7 100644 --- a/src/app/RegorConfig.ts +++ b/src/app/RegorConfig.ts @@ -17,6 +17,8 @@ import { flatten } from '../misc/flatten' import { ref } from '../reactivity/ref' import { sref } from '../reactivity/sref' +export type PropValidationMode = 'throw' | 'warn' | 'off' + export class RegorConfig { static getDefault(): RegorConfig { return ( @@ -74,6 +76,16 @@ export class RegorConfig { useInterpolation = true + /** + * Controls how `head.validateProps(...)` behaves when a validator fails. + * + * - `'throw'` (default): rethrows the validation error immediately. + * - `'warn'`: forwards the validation error to `warningHandler.warning(...)` + * and continues. + * - `'off'`: skips runtime prop validation entirely. + */ + propValidationMode: PropValidationMode = 'throw' + constructor(globalContext?: Record) { this.setDirectives('r-') if (globalContext) { diff --git a/src/app/propValidators.ts b/src/app/propValidators.ts index 16e3a50..616b7eb 100644 --- a/src/app/propValidators.ts +++ b/src/app/propValidators.ts @@ -3,6 +3,25 @@ import { isArray, isObject } from '../common/is-what' import { isRef } from '../reactivity/isRef' import { type ComponentHead } from './ComponentHead' +/** + * Internal error used by built-in validators to carry an exact prop path and a + * plain failure detail without baking final message formatting into each + * validator. + * + * @internal + */ +export class PropValidationError extends Error { + propPath: string + detail: string + + constructor(propPath: string, detail: string) { + super(detail) + this.name = 'PropValidationError' + this.propPath = propPath + this.detail = detail + } +} + /** * Assertion-style runtime validator used by `head.validateProps(...)`. * @@ -46,8 +65,29 @@ export type InferPropValidationSchema> = : never } -const fail = (name: string, message: string): never => { - throw new Error(`Invalid prop "${name}": ${message}.`) +/** + * Throws a structured validation failure for the given prop path. + * + * This is the preferred way for custom validators to fail because it preserves + * the exact prop path for nested validators such as `shape(...)`, `arrayOf(...)`, + * and `refOf(...)`. `ComponentHead.validateProps(...)` then wraps that failure + * with the component host information. + * + * Example: + * ```ts + * const isNonEmptyString: PropValidator = (value, name) => { + * if (typeof value !== 'string' || value.trim() === '') { + * pval.fail(name, 'expected non-empty string') + * } + * } + * ``` + * + * @param name - Prop name or nested prop path being validated. + * @param message - Failure detail without the final prop/component prefix. + * @throws PropValidationError Always throws. + */ +export const fail = (name: string, message: string): never => { + throw new PropValidationError(name, `${message}.`) } const describeValue = (value: unknown): string => { @@ -207,15 +247,23 @@ export const refOf = ( /** * Built-in prop-validator namespace used with `head.validateProps(...)`. * + * This namespace includes both ready-made validators and composition helpers. + * Custom validators can also use `pval.fail(...)` to produce the same + * structured failure shape as Regor's built-in validators. + * * Example: * ```ts * head.validateProps({ * title: pval.isString, * count: pval.optional(pval.isNumber), + * meta: pval.shape({ + * slug: pval.isString, + * }), * }) * ``` */ export const pval = { + fail, isString, isNumber, isBoolean, diff --git a/src/bind/ComponentBinder.ts b/src/bind/ComponentBinder.ts index ad89314..6cb95fd 100644 --- a/src/bind/ComponentBinder.ts +++ b/src/bind/ComponentBinder.ts @@ -189,6 +189,7 @@ export class ComponentBinder { capturedContext, startOfComponent, endOfComponent, + binder.__config.propValidationMode, ) const componentCtx = useScope(() => { return ( diff --git a/src/index.ts b/src/index.ts index 3288ac1..b66782c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export type { PropValidator, } from './app/propValidators' export { pval } from './app/propValidators' +export type { PropValidationMode } from './app/RegorConfig' export { RegorConfig } from './app/RegorConfig' export { toFragment } from './app/toFragment' export { toJsonTemplate } from './app/toJsonTemplate' diff --git a/tests/app/component-head.spec.ts b/tests/app/component-head.spec.ts index cfb0301..0d31bfa 100644 --- a/tests/app/component-head.spec.ts +++ b/tests/app/component-head.spec.ts @@ -8,6 +8,7 @@ import { pushScope, setScope, } from '../../src/composition/stack' +import { warningHandler } from '../../src/log/warnings' test('component head emits custom events from source element', () => { const host = document.createElement('div') @@ -15,7 +16,7 @@ test('component head emits custom events from source element', () => { const end = document.createComment('e') host.appendChild(start) host.appendChild(end) - const head = new ComponentHead({}, host, [], start, end) + const head = new ComponentHead({}, host, [], start, end, 'throw') const spy = vi.fn() host.addEventListener('save', spy) @@ -42,7 +43,7 @@ test('component head unmount removes nodes between markers and calls unmounted h setScope(ctx) ;(peekScope() as any).onUnmounted.push(stop) popScope() - const head = new ComponentHead({}, host, [ctx], start, end) + const head = new ComponentHead({}, host, [ctx], start, end, 'throw') head.unmount() await new Promise((resolve) => setTimeout(resolve, 5)) @@ -71,6 +72,7 @@ test('component head findContext returns first matching parent context', () => { [other, parent1, parent2 as any], start, end, + 'throw', ) expect(head.findContext(ParentContext)).toBe(parent1) @@ -86,7 +88,7 @@ test('component head requireContext throws when parent context is missing', () = const host = document.createElement('div') const start = document.createComment('s') const end = document.createComment('e') - const head = new ComponentHead({}, host, [], start, end) + const head = new ComponentHead({}, host, [], start, end, 'throw') expect(() => head.requireContext(ParentContext)).toThrow( `${ParentContext} was not found in the context stack at occurrence 0.`, @@ -120,7 +122,7 @@ test('component head validateProps validates selected props without mutating pro retries: null, }, } - const head = new ComponentHead(props, host, [], start, end) + const head = new ComponentHead(props, host, [], start, end, 'throw') expect(() => head.validateProps({ @@ -152,6 +154,7 @@ test('component head validateProps throws with nested prop path details', () => [], start, end, + 'throw', ) expect(() => @@ -160,7 +163,7 @@ test('component head validateProps throws with nested prop path details', () => tags: pval.arrayOf(pval.isString), }), }), - ).toThrow('Invalid prop "meta.tags[1]": expected string.') + ).toThrow('Invalid prop "meta.tags[1]" on
: expected string.') }) test('component head validator utilities support class and ref validation', () => { @@ -180,6 +183,7 @@ test('component head validator utilities support class and ref validation', () = [], start, end, + 'throw', ) expect(() => @@ -202,11 +206,12 @@ test('component head validateProps accepts custom user validators', () => { [], start, end, + 'throw', ) const isNonEmptyString: PropValidator = (value, name) => { if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`Invalid prop "${name}": expected non-empty string.`) + pval.fail(name, 'expected non-empty string') } } @@ -224,13 +229,14 @@ test('component head validateProps accepts custom user validators', () => { [], start, end, + 'throw', ) expect(() => badHead.validateProps({ title: isNonEmptyString, }), - ).toThrow('Invalid prop "title": expected non-empty string.') + ).toThrow('Invalid prop "title" on
: expected non-empty string.') }) test('component head validateProps custom validators can use head context', () => { @@ -256,6 +262,7 @@ test('component head validateProps custom validators can use head context', () = [parent], start, end, + 'throw', ) const startsWithParentPrefix: PropValidator = ( @@ -277,3 +284,68 @@ test('component head validateProps custom validators can use head context', () = }), ).not.toThrow() }) + +test('component head validateProps warns instead of throwing in warn mode', () => { + const host = document.createElement('div') + const start = document.createComment('s') + const end = document.createComment('e') + const warnSpy = vi + .spyOn(warningHandler, 'warning') + .mockImplementation(() => {}) + + try { + const head = new ComponentHead( + { + count: 'bad', + }, + host, + [], + start, + end, + 'warn', + ) + + expect(() => + head.validateProps({ + count: pval.isNumber, + }), + ).not.toThrow() + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(String(warnSpy.mock.calls[0][0])).toContain( + 'Invalid prop "count" on
: expected number.', + ) + } finally { + warnSpy.mockRestore() + } +}) + +test('component head validateProps skips validation in off mode', () => { + const host = document.createElement('div') + const start = document.createComment('s') + const end = document.createComment('e') + const warnSpy = vi + .spyOn(warningHandler, 'warning') + .mockImplementation(() => {}) + + try { + const head = new ComponentHead( + { + count: 'bad', + }, + host, + [], + start, + end, + 'off', + ) + + expect(() => + head.validateProps({ + count: pval.isNumber, + }), + ).not.toThrow() + expect(warnSpy).not.toHaveBeenCalled() + } finally { + warnSpy.mockRestore() + } +}) diff --git a/tests/app/prop-validation.spec.ts b/tests/app/prop-validation.spec.ts index fe656b6..bc8d4a0 100644 --- a/tests/app/prop-validation.spec.ts +++ b/tests/app/prop-validation.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'vitest' +import { expect, test, vi } from 'vitest' import { ComponentHead, @@ -8,7 +8,9 @@ import { pval, type Ref, ref, + RegorConfig, } from '../../src' +import { warningHandler } from '../../src/log/warnings' test('component props validation allows validated props to be used during mount', () => { const root = document.createElement('div') @@ -136,6 +138,109 @@ test('component props validation throws before invalid component mount completes template: html``, }, ), - ).toThrow('Invalid prop "count": expected number.') + ).toThrow('Invalid prop "count" on : expected number.') expect(root.querySelector('.summary')).toBeNull() }) + +test('component props validation warns and continues mount when config mode is warn', () => { + const root = document.createElement('div') + const cfg = new RegorConfig() + cfg.propValidationMode = 'warn' + const warnSpy = vi + .spyOn(warningHandler, 'warning') + .mockImplementation(() => {}) + + type CountProps = { + count: number + summary?: string + } + + const validatedCard = defineComponent( + html`
{{ summary }}
`, + { + props: ['count'], + context: (head: ComponentHead) => { + head.validateProps({ + count: pval.isNumber, + }) + + return { + ...head.props, + summary: String(head.props.count), + } + }, + }, + ) + + try { + expect(() => + createApp( + { + components: { validatedCard }, + }, + { + element: root, + template: html``, + }, + cfg, + ), + ).not.toThrow() + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(String(warnSpy.mock.calls[0][0])).toContain( + 'Invalid prop "count" on : expected number.', + ) + expect(root.querySelector('.summary')?.textContent).toBe('oops') + } finally { + warnSpy.mockRestore() + } +}) + +test('component props validation can be disabled through config mode off', () => { + const root = document.createElement('div') + const cfg = new RegorConfig() + cfg.propValidationMode = 'off' + const warnSpy = vi + .spyOn(warningHandler, 'warning') + .mockImplementation(() => {}) + + type CountProps = { + count: number + summary?: string + } + + const validatedCard = defineComponent( + html`
{{ summary }}
`, + { + props: ['count'], + context: (head: ComponentHead) => { + head.validateProps({ + count: pval.isNumber, + }) + + return { + ...head.props, + summary: String(head.props.count), + } + }, + }, + ) + + try { + expect(() => + createApp( + { + components: { validatedCard }, + }, + { + element: root, + template: html``, + }, + cfg, + ), + ).not.toThrow() + expect(warnSpy).not.toHaveBeenCalled() + expect(root.querySelector('.summary')?.textContent).toBe('oops') + } finally { + warnSpy.mockRestore() + } +}) diff --git a/tests/app/regor-config.spec.ts b/tests/app/regor-config.spec.ts index 2618ab5..5dd6f07 100644 --- a/tests/app/regor-config.spec.ts +++ b/tests/app/regor-config.spec.ts @@ -41,3 +41,14 @@ test('RegorConfig registers named component and allows updates', () => { expect(cfg.__directiveMap['@x']).toBeDefined() expect(cfg.__builtInNames.dynamic).toBe('__dyn__') }) + +test('RegorConfig propValidationMode defaults to throw and can be changed', () => { + const cfg = new RegorConfig() + expect(cfg.propValidationMode).toBe('throw') + + cfg.propValidationMode = 'warn' + expect(cfg.propValidationMode).toBe('warn') + + cfg.propValidationMode = 'off' + expect(cfg.propValidationMode).toBe('off') +})