Skip to content

Commit 754a2a5

Browse files
authored
Merge pull request #92 from koculu/component-auto-props-context-isolation
component props are isolated when autoProps is enabled.
2 parents 98c9f67 + 5923e8a commit 754a2a5

8 files changed

Lines changed: 147 additions & 75 deletions

File tree

‎docs-site/src/content/docs/api/defineComponent.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ To define a Regor component, call the `defineComponent` function with the follow
2222

2323
- `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.
2424
- `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.
25-
- `head.autoProps`: Automatically assigns properties defined in the `:context` binding to the component context. Defaults to `true`.
25+
- `head.autoProps`: Automatically merges `head.props` into the returned component context. Defaults to `true`.
2626
- `head.entangle`: Keeps refs defined in the component context entangled with `head.props` refs. Defaults to `true`.
2727
- `head.enableSwitch`: Enables slot context switching to the parent. Defaults to `false`.
2828
- `head.onAutoPropsAssigned`: Callback invoked after auto props get assigned to the component context.
@@ -160,6 +160,8 @@ The `defineComponent` function returns a component object with the following pro
160160

161161
- The component context is configured through the `options.context` callback, which defines the behavior and data of your component.
162162
- Runtime prop validation is opt-in and local to `context(head)` through `head.validateProps(...)`.
163+
- `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.
164+
- `head.autoProps = false` disables that merge. In that mode, omitted names are not added automatically, so normal parent-context lookup can still resolve them.
163165

164166
- 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.
165167

‎docs-site/src/content/docs/guide/components.md‎

Lines changed: 8 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -105,46 +105,6 @@ const Child = defineComponent('<div></div>', {
105105
})
106106
```
107107

108-
### Scope collision note
109-
110-
Component template lookup is not fully isolated from parent contexts.
111-
112-
If a component template reads a name that is not present on the component's own
113-
context object, Regor can continue resolving that name from outer contexts.
114-
This is flexible, but it also means omitted fields can resolve from the parent
115-
when names collide.
116-
117-
Example:
118-
119-
```ts
120-
const NoteCard = defineComponent(
121-
html`<article>
122-
<h3 r-text="title"></h3>
123-
<p r-text="note"></p>
124-
</article>`,
125-
{
126-
props: ['title', 'note'],
127-
context: (head) => ({
128-
title: head.props.title,
129-
// `note` is omitted here
130-
}),
131-
},
132-
)
133-
```
134-
135-
```html
136-
<NoteCard :title="title"></NoteCard>
137-
```
138-
139-
If the parent context also has `note`, the component template can resolve that
140-
parent `note`.
141-
142-
To avoid surprises:
143-
144-
1. Assign every component field you intend to use in the template.
145-
2. For optional props, map them explicitly from `head.props`.
146-
3. Use `head.requireContext(...)` / `head.findContext(...)` when parent access is intentional.
147-
148108
## Runtime Prop Validation
149109

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

422382
### autoProps and entangle behavior
423383

424-
1. `head.autoProps = false`: component author maps from `head.props` manually.
425-
2. `head.autoProps = true` + `head.entangle = true`: ref-to-ref inputs are two-way entangled.
426-
3. `head.autoProps = true` + `head.entangle = false`: ref-to-ref inputs are initial snapshot only.
427-
4. Primitive/object values targeting existing component ref fields are applied to those refs.
428-
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.
384+
1. `head.autoProps = true`: Regor merges `head.props` into the returned component context.
385+
2. With `head.autoProps = true`, omitted declared prop names are added as local `undefined` only when the component did not already define that field.
386+
3. That keeps declared prop names local to the component and prevents same-named parent values from being resolved by accident.
387+
4. `head.autoProps = false`: component author maps from `head.props` manually, and omitted names are not isolated automatically.
388+
5. `head.autoProps = true` + `head.entangle = true`: ref-to-ref inputs are two-way entangled.
389+
6. `head.autoProps = true` + `head.entangle = false`: ref-to-ref inputs are initial snapshot only.
390+
7. Primitive/object values targeting existing component ref fields are applied to those refs.
391+
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.
429392

430393
## Slots
431394

‎docs-site/src/content/docs/guide/templates.md‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,6 @@ Special identifiers:
8282

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

85-
For component templates, this also means omitted names can still resolve from
86-
outer contexts when they are not defined on the component's own context object.
87-
That behavior is flexible, but component authors should assign every template
88-
field they rely on when they want predictable component-local lookup.
89-
9085
## Supported Expression Syntax (Runtime)
9186

9287
Backed by Regor’s jsep/regorEval pipeline, templates support:

‎docs-site/src/content/docs/guide/troubleshooting.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ Correct routing:
9999
attribute channel on component hosts (fallthrough), not `head.props` object assignment.
100100

101101
If component sets `head.autoProps = false`, you must map `head.props` manually in `context(head)`.
102+
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.
102103

103104
## Parent-Child Ref Sync Feels Wrong
104105

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "regor",
3-
"version": "1.5.3",
3+
"version": "1.5.4",
44
"description": "A modern UI framework for web and desktop applications, inspired by Vue's concepts and powered by simplicity and flexibility.",
55
"author": "Ahmed Yasin Koculu",
66
"license": "MIT",

‎src/app/ComponentHead.ts‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -102,24 +102,36 @@ export class ComponentHead<
102102
ctx: IRegorContext[]
103103

104104
/**
105-
* Controls whether Regor should automatically apply incoming `head.props`
106-
* values to the component context after `context(head)` returns.
105+
* Controls whether Regor automatically merges parent-provided component inputs
106+
* into the object returned by `context(head)`.
107107
*
108-
* Think of it as "auto wire parent inputs into my component fields".
108+
* There are two runtime data sources for a component:
109+
*
110+
* 1. Parent inputs:
111+
* values passed from the component host, such as `:title="abc"` or
112+
* `:context="{ ... }"`. These are exposed on `head.props`.
113+
*
114+
* 2. Component context:
115+
* the object returned by `context(head)`. This is the component's own local
116+
* state.
117+
*
118+
* `autoProps` controls the third step: how those two sources are merged.
109119
*
110120
* - `true` (default):
111-
* - If a key exists in `head.props` but does not exist on the object
112-
* returned by `context(head)`, Regor adds that key to component context.
113-
* - Existing ref fields can receive incoming values automatically.
114-
* - Ref-to-ref inputs can be entangled when `head.entangle` is enabled.
115-
* - `false`:
116-
* - Regor does not auto-apply props.
117-
* - You fully control mapping manually inside `context(head)`.
121+
* Regor automatically applies `head.props` to the component context.
122+
* If a parent input name is missing from the component context, Regor adds it.
123+
* Isolates the declared component properties,
124+
* so a component does not accidentally inherit unwanted values from its ancestors.
118125
*
119-
* Use `false` when you need strict custom mapping/validation/transforms
120-
* before any value touches component state.
126+
* - `false`:
127+
* Regor does not merge `head.props` into the returned context.
128+
* The component author must map values manually from `head.props`.
129+
* Disables isolation for declared props.
121130
*
122-
* "Missing key" is always checked against the returned component context object.
131+
* In short:
132+
* - `head.props` = what the parent passed
133+
* - `context(head)` return value = what the component defines
134+
* - `autoProps` = whether Regor should merge the first into the second
123135
*
124136
* Example (auto add):
125137
* ```ts

‎src/bind/ComponentBinder.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export class ComponentBinder {
121121
const contextName = binder.__config.__builtInNames.context
122122
const contextAliasName = binder.__config.__builtInNames.contextAlias
123123
const bindName = binder.__config.__builtInNames.bind
124-
124+
const definedProps = registeredComponent.props?.map(camelize) ?? []
125125
const getProps = (
126126
component: HTMLElement,
127127
capturedContext: any[],
@@ -136,9 +136,7 @@ export class ComponentBinder {
136136
binder.__bind(contextDirective, component, contextAliasName)
137137
}
138138

139-
let definedProps = registeredComponent.props
140-
if (!definedProps || definedProps.length === 0) return
141-
definedProps = definedProps.map(camelize)
139+
if (definedProps.length === 0) return
142140
const definedPropsByLowerCase = new Map(
143141
definedProps.map((definedProp) => [
144142
definedProp.toLowerCase(),
@@ -229,6 +227,12 @@ export class ComponentBinder {
229227
} else componentCtx[key] = propsValue
230228
// Key does not exist on component context yet; auto-add from inputs.
231229
}
230+
231+
// component prop isolation when head.autoProps is enabled.
232+
for (const key of definedProps) {
233+
if (key in componentCtx) continue
234+
componentCtx[key] = undefined
235+
}
232236
head.onAutoPropsAssigned?.()
233237
}
234238
return { componentCtx, head }

‎tests/app/binders-reactivity-e2e.spec.ts‎

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ test('e2e: component prop reactivity keeps disabled state in sync', async () =>
272272
)
273273
})
274274

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

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

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

310+
expect(titleEl.textContent).toBe('Child title')
311+
expect(noteEl.textContent ?? '').toBe('')
312+
313+
note('updated parent note')
314+
expect(noteEl.textContent ?? '').toBe('')
315+
},
316+
)
317+
})
318+
319+
test('e2e: omitted kebab-case declared prop is isolated through camelized child key', async () => {
320+
const title = ref('Child title')
321+
const pendingDeleteHostname = ref('parent-host')
322+
323+
const hostCard = defineComponent(
324+
html`<article>
325+
<h3 id="title-kebab" r-text="title"></h3>
326+
<p id="pending-kebab" r-text="pendingDeleteHostname"></p>
327+
</article>`,
328+
{
329+
props: ['title', 'pending-delete-hostname'],
330+
context: (head) => {
331+
head.autoProps = true
332+
return {
333+
title: head.props.title,
334+
// `pendingDeleteHostname` is intentionally omitted here. The declared
335+
// prop name is kebab-case, but the child template reads camelCase.
336+
// The auto-props isolation path must camelize the declared prop name
337+
// before materializing the missing field as `undefined`.
338+
}
339+
},
340+
},
341+
)
342+
343+
await withApp(
344+
{
345+
title,
346+
pendingDeleteHostname,
347+
components: { hostCard },
348+
},
349+
`<HostCard :title="title"></HostCard>`,
350+
(root) => {
351+
const titleEl = root.querySelector('#title-kebab') as HTMLElement | null
352+
const pendingEl = root.querySelector(
353+
'#pending-kebab',
354+
) as HTMLElement | null
355+
if (!titleEl || !pendingEl) throw new Error('missing rendered host card')
356+
357+
expect(titleEl.textContent).toBe('Child title')
358+
expect(pendingEl.textContent ?? '').toBe('')
359+
360+
pendingDeleteHostname('updated-parent-host')
361+
expect(pendingEl.textContent ?? '').toBe('')
362+
},
363+
)
364+
})
365+
366+
test('e2e: omitted optional component prop resolves from parent when autoProps is disabled', async () => {
367+
const title = ref('Child title')
368+
const note = ref('parent note')
369+
370+
const noteCard = defineComponent(
371+
html`<article>
372+
<h3 id="title-off" r-text="title"></h3>
373+
<p id="note-off" r-text="note"></p>
374+
</article>`,
375+
{
376+
props: ['title', 'note'],
377+
context: (head) => {
378+
head.autoProps = false
379+
return {
380+
title: head.props.title,
381+
// `note` is intentionally omitted. With autoProps disabled, the
382+
// component context never gets a local `note` field, so lookup can
383+
// still fall through to the parent context.
384+
}
385+
},
386+
},
387+
)
388+
389+
await withApp(
390+
{
391+
title,
392+
note,
393+
components: { noteCard },
394+
},
395+
`<NoteCard :title="title"></NoteCard>`,
396+
(root) => {
397+
const titleEl = root.querySelector('#title-off') as HTMLElement | null
398+
const noteEl = root.querySelector('#note-off') as HTMLElement | null
399+
if (!titleEl || !noteEl) throw new Error('missing rendered note card')
400+
306401
expect(titleEl.textContent).toBe('Child title')
307402
expect(noteEl.textContent).toBe('parent note')
308403

0 commit comments

Comments
 (0)