Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/api/defineComponent.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ To define a Regor component, call the `defineComponent` function with the follow

- `options` (optional): An array of strings that defines component properties or an object that allows you to configure various options for the component, such as whether to use interpolation, props, the component's default name, or the component context.
- `context` (optional): A function that defines the Regor context for the component. This function receives a `ComponentHead` object, which you can use to specify the component's behavior and props. It should return the Regor context.
- `head.autoProps`: Automatically assigns properties defined in the `:context` binding to the component context. Defaults to `true`.
- `head.autoProps`: Automatically merges `head.props` into the returned component context. Defaults to `true`.
- `head.entangle`: Keeps refs defined in the component context entangled with `head.props` refs. Defaults to `true`.
- `head.enableSwitch`: Enables slot context switching to the parent. Defaults to `false`.
- `head.onAutoPropsAssigned`: Callback invoked after auto props get assigned to the component context.
Expand Down Expand Up @@ -160,6 +160,8 @@ The `defineComponent` function returns a component object with the following pro

- The component context is configured through the `options.context` callback, which defines the behavior and data of your component.
- Runtime prop validation is opt-in and local to `context(head)` through `head.validateProps(...)`.
- `head.autoProps = true` merges `head.props` into the returned component context and also keeps omitted declared prop names local by adding `undefined` only when the component did not already define that field.
- `head.autoProps = false` disables that merge. In that mode, omitted names are not added automatically, so normal parent-context lookup can still resolve them.

- The `options` parameter allows you to configure various aspects of the component, such as enabling or disabling interpolation, specifying props, setting a default name, or providing the component context.

Expand Down
53 changes: 8 additions & 45 deletions docs-site/src/content/docs/guide/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,46 +105,6 @@ const Child = defineComponent('<div></div>', {
})
```

### Scope collision note

Component template lookup is not fully isolated from parent contexts.

If a component template reads a name that is not present on the component's own
context object, Regor can continue resolving that name from outer contexts.
This is flexible, but it also means omitted fields can resolve from the parent
when names collide.

Example:

```ts
const NoteCard = defineComponent(
html`<article>
<h3 r-text="title"></h3>
<p r-text="note"></p>
</article>`,
{
props: ['title', 'note'],
context: (head) => ({
title: head.props.title,
// `note` is omitted here
}),
},
)
```

```html
<NoteCard :title="title"></NoteCard>
```

If the parent context also has `note`, the component template can resolve that
parent `note`.

To avoid surprises:

1. Assign every component field you intend to use in the template.
2. For optional props, map them explicitly from `head.props`.
3. Use `head.requireContext(...)` / `head.findContext(...)` when parent access is intentional.

## Runtime Prop Validation

Regor lets component authors validate incoming props inside `context(head)`.
Expand Down Expand Up @@ -421,11 +381,14 @@ It does not go through declared-prop resolution or `:context` object assignment.

### autoProps and entangle behavior

1. `head.autoProps = false`: component author maps from `head.props` manually.
2. `head.autoProps = true` + `head.entangle = true`: ref-to-ref inputs are two-way entangled.
3. `head.autoProps = true` + `head.entangle = false`: ref-to-ref inputs are initial snapshot only.
4. Primitive/object values targeting existing component ref fields are applied to those refs.
5. For single-prop bindings, wrapping an expression in `ref(...)` gives the child a live reactive prop source even when the expression evaluates to a non-ref.
1. `head.autoProps = true`: Regor merges `head.props` into the returned component context.
2. With `head.autoProps = true`, omitted declared prop names are added as local `undefined` only when the component did not already define that field.
3. That keeps declared prop names local to the component and prevents same-named parent values from being resolved by accident.
4. `head.autoProps = false`: component author maps from `head.props` manually, and omitted names are not isolated automatically.
5. `head.autoProps = true` + `head.entangle = true`: ref-to-ref inputs are two-way entangled.
6. `head.autoProps = true` + `head.entangle = false`: ref-to-ref inputs are initial snapshot only.
7. Primitive/object values targeting existing component ref fields are applied to those refs.
8. For single-prop bindings, wrapping an expression in `ref(...)` gives the child a live reactive prop source even when the expression evaluates to a non-ref.

## Slots

Expand Down
5 changes: 0 additions & 5 deletions docs-site/src/content/docs/guide/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,6 @@ Special identifiers:

Regor auto-unrefs refs in expressions, so template code uses `user.name`, not `user.name()` / `.value`.

For component templates, this also means omitted names can still resolve from
outer contexts when they are not defined on the component's own context object.
That behavior is flexible, but component authors should assign every template
field they rely on when they want predictable component-local lookup.

## Supported Expression Syntax (Runtime)

Backed by Regor’s jsep/regorEval pipeline, templates support:
Expand Down
1 change: 1 addition & 0 deletions docs-site/src/content/docs/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Correct routing:
attribute channel on component hosts (fallthrough), not `head.props` object assignment.

If component sets `head.autoProps = false`, you must map `head.props` manually in `context(head)`.
Also note that omitted declared prop names are not isolated automatically in that mode, so same-named parent values can still resolve through normal template lookup.

## Parent-Child Ref Sync Feels Wrong

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "regor",
"version": "1.5.3",
"version": "1.5.4",
"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",
Expand Down
38 changes: 25 additions & 13 deletions src/app/ComponentHead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,24 +102,36 @@ export class ComponentHead<
ctx: IRegorContext[]

/**
* Controls whether Regor should automatically apply incoming `head.props`
* values to the component context after `context(head)` returns.
* Controls whether Regor automatically merges parent-provided component inputs
* into the object returned by `context(head)`.
*
* Think of it as "auto wire parent inputs into my component fields".
* There are two runtime data sources for a component:
*
* 1. Parent inputs:
* values passed from the component host, such as `:title="abc"` or
* `:context="{ ... }"`. These are exposed on `head.props`.
*
* 2. Component context:
* the object returned by `context(head)`. This is the component's own local
* state.
*
* `autoProps` controls the third step: how those two sources are merged.
*
* - `true` (default):
* - If a key exists in `head.props` but does not exist on the object
* returned by `context(head)`, Regor adds that key to component context.
* - Existing ref fields can receive incoming values automatically.
* - Ref-to-ref inputs can be entangled when `head.entangle` is enabled.
* - `false`:
* - Regor does not auto-apply props.
* - You fully control mapping manually inside `context(head)`.
* Regor automatically applies `head.props` to the component context.
* If a parent input name is missing from the component context, Regor adds it.
* Isolates the declared component properties,
* so a component does not accidentally inherit unwanted values from its ancestors.
*
* Use `false` when you need strict custom mapping/validation/transforms
* before any value touches component state.
* - `false`:
* Regor does not merge `head.props` into the returned context.
* The component author must map values manually from `head.props`.
* Disables isolation for declared props.
*
* "Missing key" is always checked against the returned component context object.
* In short:
* - `head.props` = what the parent passed
* - `context(head)` return value = what the component defines
* - `autoProps` = whether Regor should merge the first into the second
*
* Example (auto add):
* ```ts
Expand Down
12 changes: 8 additions & 4 deletions src/bind/ComponentBinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export class ComponentBinder {
const contextName = binder.__config.__builtInNames.context
const contextAliasName = binder.__config.__builtInNames.contextAlias
const bindName = binder.__config.__builtInNames.bind

const definedProps = registeredComponent.props?.map(camelize) ?? []
const getProps = (
component: HTMLElement,
capturedContext: any[],
Expand All @@ -136,9 +136,7 @@ export class ComponentBinder {
binder.__bind(contextDirective, component, contextAliasName)
}

let definedProps = registeredComponent.props
if (!definedProps || definedProps.length === 0) return
definedProps = definedProps.map(camelize)
if (definedProps.length === 0) return
const definedPropsByLowerCase = new Map(
definedProps.map((definedProp) => [
definedProp.toLowerCase(),
Expand Down Expand Up @@ -229,6 +227,12 @@ export class ComponentBinder {
} else componentCtx[key] = propsValue
// Key does not exist on component context yet; auto-add from inputs.
}

// component prop isolation when head.autoProps is enabled.
for (const key of definedProps) {
if (key in componentCtx) continue
componentCtx[key] = undefined
}
head.onAutoPropsAssigned?.()
}
return { componentCtx, head }
Expand Down
107 changes: 101 additions & 6 deletions tests/app/binders-reactivity-e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ test('e2e: component prop reactivity keeps disabled state in sync', async () =>
)
})

test('e2e: omitted optional component prop resolves from parent context when names collide', async () => {
test('e2e: omitted optional component prop stays undefined even when parent has same name', async () => {
const title = ref('Child title')
const note = ref('parent note')

Expand All @@ -283,11 +283,15 @@ test('e2e: omitted optional component prop resolves from parent context when nam
</article>`,
{
props: ['title', 'note'],
context: (head) => ({
title: head.props.title,
// `note` is intentionally omitted, so template lookup falls through
// to the parent context when the name collides.
}),
context: (head) => {
head.autoProps = true
return {
title: head.props.title,
// `note` is intentionally omitted. With auto props enabled, the
// declared-but-missing prop should still exist on the child context
// as `undefined`, so parent `note` does not leak in by name.
}
},
},
)

Expand All @@ -303,6 +307,97 @@ test('e2e: omitted optional component prop resolves from parent context when nam
const noteEl = root.querySelector('#note') as HTMLElement | null
if (!titleEl || !noteEl) throw new Error('missing rendered note card')

expect(titleEl.textContent).toBe('Child title')
expect(noteEl.textContent ?? '').toBe('')

note('updated parent note')
expect(noteEl.textContent ?? '').toBe('')
},
)
})

test('e2e: omitted kebab-case declared prop is isolated through camelized child key', async () => {
const title = ref('Child title')
const pendingDeleteHostname = ref('parent-host')

const hostCard = defineComponent(
html`<article>
<h3 id="title-kebab" r-text="title"></h3>
<p id="pending-kebab" r-text="pendingDeleteHostname"></p>
</article>`,
{
props: ['title', 'pending-delete-hostname'],
context: (head) => {
head.autoProps = true
return {
title: head.props.title,
// `pendingDeleteHostname` is intentionally omitted here. The declared
// prop name is kebab-case, but the child template reads camelCase.
// The auto-props isolation path must camelize the declared prop name
// before materializing the missing field as `undefined`.
}
},
},
)

await withApp(
{
title,
pendingDeleteHostname,
components: { hostCard },
},
`<HostCard :title="title"></HostCard>`,
(root) => {
const titleEl = root.querySelector('#title-kebab') as HTMLElement | null
const pendingEl = root.querySelector(
'#pending-kebab',
) as HTMLElement | null
if (!titleEl || !pendingEl) throw new Error('missing rendered host card')

expect(titleEl.textContent).toBe('Child title')
expect(pendingEl.textContent ?? '').toBe('')

pendingDeleteHostname('updated-parent-host')
expect(pendingEl.textContent ?? '').toBe('')
},
)
})

test('e2e: omitted optional component prop resolves from parent when autoProps is disabled', async () => {
const title = ref('Child title')
const note = ref('parent note')

const noteCard = defineComponent(
html`<article>
<h3 id="title-off" r-text="title"></h3>
<p id="note-off" r-text="note"></p>
</article>`,
{
props: ['title', 'note'],
context: (head) => {
head.autoProps = false
return {
title: head.props.title,
// `note` is intentionally omitted. With autoProps disabled, the
// component context never gets a local `note` field, so lookup can
// still fall through to the parent context.
}
},
},
)

await withApp(
{
title,
note,
components: { noteCard },
},
`<NoteCard :title="title"></NoteCard>`,
(root) => {
const titleEl = root.querySelector('#title-off') as HTMLElement | null
const noteEl = root.querySelector('#note-off') as HTMLElement | null
if (!titleEl || !noteEl) throw new Error('missing rendered note card')

expect(titleEl.textContent).toBe('Child title')
expect(noteEl.textContent).toBe('parent note')

Expand Down
Loading