Skip to content

feat: bubble functional state updaters through lift#150

Merged
megheaiulian merged 4 commits into
mainfrom
feat/lift-functional-updater
May 21, 2026
Merged

feat: bubble functional state updaters through lift#150
megheaiulian merged 4 commits into
mainfrom
feat/lift-functional-updater

Conversation

@megheaiulian

@megheaiulian megheaiulian commented May 21, 2026

Copy link
Copy Markdown
Contributor

Problem

When lift is used to delegate property ownership to a parent component, rapid-fire calls to useProperty's setter with functional updaters produce incorrect state. Each call reads stale host[property] because lift's preventDefault() blocks the local write, and the parent hasn't re-rendered yet. See #149 for full analysis.

Solution

Notify first, compute after. If defaultPrevented (i.e., lift intercepted), skip the local write. If not prevented, commit the resolved value.

Bubble the original updater function in ev.detail.updater so lift can pass it to the parent's setter. The parent's setter is itself a useProperty updater that calls resolve() against its own host[property] — the authoritative state. Meanwhile, ev.detail.value always contains the locally-resolved value for backward compatibility with non-lift listeners.

Method breakdown

Method Responsibility
resolve(valueOrUpdater) Given a value or updater function, compute [previousValue, value, updater]. Resolves functions against host[property].
notify(value, updater?) Dispatch event with { value, updater, path } in detail. value is always the resolved value. Returns event.
updater(valueOrUpdater) Orchestrate: resolve → notify → if prevented return → dedup+write. Called from constructor for init and at runtime.

updateProp, init, and commit removed — replaced by resolve + notify + inline dedup/write.

Event detail

ev.detail now always contains:

  • value: the resolved value (computed against host[property]). Always present, never undefined.
  • updater: the original updater function for functional calls, undefined for direct values. Used by lift to resolve against parent state.
  • path: unchanged.

Flow for rapid-fire calls with lift

select(item1) → updater((sel) => [...sel, item1])
  → resolve: previousValue=[], value=[item1], updater=fn
  → notify({ value: [item1], updater: fn })
  → lift: ev.preventDefault(), setter(fn)
  → parent updater: resolve against parentHost.selectedItems=[] → value=[item1]
  → parent notify → not prevented → write parentHost.selectedItems=[item1]
  → child: defaultPrevented, return (no local write)

select(item2) → updater((sel) => [...sel, item2])
  → resolve: previousValue=[] (stale), value=[item2], updater=fn
  → notify({ value: [item2], updater: fn })
  → lift: setter(fn)
  → parent updater: resolve against parentHost.selectedItems=[item1] → value=[item1, item2]
  → parent notify → not prevented → write parentHost.selectedItems=[item1, item2]
  → child: defaultPrevented, return

Each call resolves correctly at the parent level because lift passes the updater function, not the locally-resolved value.

Backward Compatibility

  • ev.detail.value: always the resolved value. Previously was the resolved value for direct calls only. Non-lift listeners that read ev.detail.value get correct values for both direct and functional updaters.
  • ev.detail.updater: new optional field — only used by lift.
  • ev.detail.path: unchanged.

Breaking changes

  • useProperty.updateProp removed (was internal, not part of public API)
  • useProperty.init removed (was internal, not part of public API)
  • Event detail shape now includes updater field alongside value
  • ev.detail.value is always the resolved value (was undefined for functional updaters)
  • lift now passes ev.detail.updater ?? ev.detail.value to setter (was ev.detail.value)
  • useProperty setter now always dispatches a change event, even if the value is unchanged

Tests

94 tests passing (89 existing + 5 new):

  • "event detail contains updater for functional calls" — verifies ev.detail.value is resolved (9, not undefined) and ev.detail.updater is the function
  • "event detail contains value for direct calls" — verifies ev.detail.value is the value and ev.detail.updater is undefined
  • "lift bubbles functional updater to parent" — child calls functional setter, parent lift intercepts
  • "rapid-fire functional updaters with lift accumulate correctly" — 3 synchronous setItems((items) => [...items, x]) calls produce a,b,c
  • "lift with direct value still works" — direct values still work with lift

- useProperty now passes original updater function in ev.detail.updater
- lift passes updater fn to parent setter so it resolves against parent state
- Notify first, compute after: if defaultPrevented, skip local computation
- Add init/commit/updater/notify methods replacing updateProp
- Event detail now includes both value and updater fields
- Dedup direct values before notify (functional updaters always notify)

Fixes rapid-fire functional updater calls producing incorrect state
when used with lift, because the child's local state was blocked by
preventDefault() and hadn't yet been updated by parent re-render.

Closes #149
@netlify

netlify Bot commented May 21, 2026

Copy link
Copy Markdown

Deploy Preview for pionjs ready!

Name Link
🔨 Latest commit aaa59cb
🔍 Latest deploy log https://app.netlify.com/projects/pionjs/deploys/6a0f100bb8df3f00085bbf2e
😎 Deploy Preview https://deploy-preview-150--pionjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@changeset-bot

changeset-bot Bot commented May 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aaa59cb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@pionjs/pion Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

updater now always dispatches the change event, even for unchanged
direct values. This ensures lift always receives the updater function.
The commit method still handles deduplication via Object.is to avoid
redundant host writes.
…il.value

- resolve() computes previousValue, value, updater from valueOrUpdater
- notify() takes resolved value + optional updater, always populates
  ev.detail.value with the resolved value (not undefined for functions)
- updater() orchestrates: resolve → notify → if prevented return →
  dedup+write
- ev.detail.value is always the resolved value, backward compatible
- ev.detail.updater carries original function for lift
- Always dispatch event (no Object.is short-circuit before notify)

@cristinecula cristinecula left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

@megheaiulian megheaiulian merged commit 6018594 into main May 21, 2026
9 checks passed
@megheaiulian megheaiulian deleted the feat/lift-functional-updater branch May 21, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants