Fix merge fragments (#711)
* Restore elUniqId, removeOnLoad * Store cleanup function only if not removed * Restore elUniqId (again) * Fix empty value being replaced * Rehash the cleanup functions * Cleanup * Fix persist example * Use el.id * Remove `removeOnLoad` * Restore check for existing mutationObserver * Move * Add release notes * Improve tests
This commit is contained in:
parent
a7df17505f
commit
e40db72ed5
25
CHANGELOG.md
25
CHANGELOG.md
|
@ -4,27 +4,10 @@ Each tagged version of Datastar is accompanied by a release note. Read the [rele
|
|||
|
||||
# WIP Release Notes
|
||||
|
||||
## v1.0.0-beta.8
|
||||
|
||||
### Added
|
||||
|
||||
- Added the ability for checkbox input elements to set bound signals to an array of values by predefining the signal as an array ([#664](https://github.com/starfederation/datastar/issues/674)).
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Idiomorph to version [0.7.2](https://github.com/bigskysoftware/idiomorph/blob/main/CHANGELOG.md#072---2025-02-20).
|
||||
- When using `data-bind` on an element, the signal value now defaults to the element’s `value` attribute, provided the signal has not already been defined ([#685](https://github.com/starfederation/datastar/issues/685)).
|
||||
- The expression passed into `data-on-signals-change` is no longer executed on page load ([#682](https://github.com/starfederation/datastar/issues/682)).
|
||||
- Whitespace is now maintained in merged fragments ([#658](https://github.com/starfederation/datastar/issues/658)).
|
||||
- Attribute plugins now define a hash of their contents, preventing duplicate applies ([#691](https://github.com/starfederation/datastar/issues/691)).
|
||||
- Attribute plugins are now applied to the `html` element instead of the `body` element ([#691](https://github.com/starfederation/datastar/issues/691)).
|
||||
## v1.0.0-beta.9
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a bug in which `datastar-remove-fragments` events were not having any effect ([#664](https://github.com/starfederation/datastar/issues/664)).
|
||||
- Fixed a bug in which `datastarNaN` could be used as an auto-generated element ID ([#679](https://github.com/starfederation/datastar/issues/679)).
|
||||
- Fixed a bug in which `data-attr` was not removing the element attribute when using object syntax and the value was `false` ([#693](https://github.com/starfederation/datastar/issues/693)).
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the ability to import the Datastar class. The `apply`, `load`, and `setAlias` functions are exported instead.
|
||||
- Fixed a bug in which `data-signals` was being reapplied each time any attribute changed on an element ([#709](https://github.com/starfederation/datastar/issues/709)).
|
||||
- Fixed a bug in which focus was not being restored to input elements after merging fragments ([#710](https://github.com/starfederation/datastar/issues/710)).
|
||||
- Fixed a bug in which signals bound to text input elements with a `value` attribute were being reset to the value when the entered value was empty.
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework.
|
||||
|
||||
Getting started is as easy as adding a single 14.3 KiB script tag to your HTML.
|
||||
Getting started is as easy as adding a single 14.4 KiB script tag to your HTML.
|
||||
|
||||
```html
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.8/bundles/datastar.js"></script>
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -10,7 +10,7 @@
|
|||
|
||||
Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework.
|
||||
|
||||
Getting started is as easy as adding a single 14.3 KiB script tag to your HTML.
|
||||
Getting started is as easy as adding a single 14.4 KiB script tag to your HTML.
|
||||
|
||||
```html
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.8/bundles/datastar.js"></script>
|
||||
|
|
|
@ -25,23 +25,25 @@ const plugins: AttributePlugin[] = []
|
|||
const actions: ActionPlugins = {}
|
||||
const watchers: WatcherPlugin[] = []
|
||||
|
||||
// Map of cleanup functions by element ID, keyed by a dataset key-value hash
|
||||
const removals = new Map<string, Map<number, OnRemovalFn>>()
|
||||
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
|
||||
let alias = ''
|
||||
export function setAlias(value: string) {
|
||||
alias = value
|
||||
}
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
|
||||
// Map of cleanup functions by element, keyed by the dataset key and value
|
||||
const removals = new Map<Element, Map<number, OnRemovalFn>>()
|
||||
|
||||
export function load(...pluginsToLoad: DatastarPlugin[]) {
|
||||
for (const plugin of pluginsToLoad) {
|
||||
const ctx: InitContext = {
|
||||
plugin,
|
||||
signals,
|
||||
effect: (cb: () => void): OnRemovalFn => effect(cb),
|
||||
actions: actions,
|
||||
plugin,
|
||||
apply,
|
||||
actions,
|
||||
removals,
|
||||
applyToElement,
|
||||
}
|
||||
|
||||
let globalInitializer: GlobalInitializer | undefined
|
||||
|
@ -79,14 +81,19 @@ export function load(...pluginsToLoad: DatastarPlugin[]) {
|
|||
})
|
||||
}
|
||||
|
||||
// Apply all plugins to all elements in the DOM
|
||||
export function apply() {
|
||||
applyToElement(document.documentElement)
|
||||
|
||||
observe()
|
||||
}
|
||||
|
||||
// Apply all plugins to the element and its children
|
||||
export function apply(
|
||||
rootElement: HTMLorSVGElement = document.documentElement,
|
||||
) {
|
||||
function applyToElement(rootElement: HTMLorSVGElement) {
|
||||
walkDOM(rootElement, (el) => {
|
||||
// Check if the element has any data attributes already
|
||||
const toApply = new Array<string>()
|
||||
const elCleanups = removals.get(el) || new Map()
|
||||
const elCleanups = removals.get(el.id) || new Map()
|
||||
const toCleanup = new Map<number, OnRemovalFn>([...elCleanups])
|
||||
const hashes = new Map<string, number>()
|
||||
|
||||
|
@ -112,14 +119,14 @@ export function apply(
|
|||
}
|
||||
|
||||
// Clean up any old plugins and apply the new ones
|
||||
for (const [_, cleanup] of toCleanup) cleanup()
|
||||
for (const [_, cleanup] of toCleanup) {
|
||||
cleanup()
|
||||
}
|
||||
for (const key of toApply) {
|
||||
const h = hashes.get(key)!
|
||||
applyAttributePlugin(el, key, h)
|
||||
}
|
||||
})
|
||||
|
||||
observe()
|
||||
}
|
||||
|
||||
// Set up a mutation observer to run plugin removal and apply functions
|
||||
|
@ -151,19 +158,19 @@ function observe() {
|
|||
}
|
||||
}
|
||||
for (const el of toRemove) {
|
||||
const elTracking = removals.get(el)
|
||||
const elTracking = removals.get(el.id)
|
||||
if (elTracking) {
|
||||
for (const [h, cleanup] of elTracking) {
|
||||
for (const [hash, cleanup] of elTracking) {
|
||||
cleanup()
|
||||
elTracking.delete(h)
|
||||
elTracking.delete(hash)
|
||||
}
|
||||
if (elTracking.size === 0) {
|
||||
removals.delete(el)
|
||||
removals.delete(el.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const el of toApply) {
|
||||
apply(el)
|
||||
applyToElement(el)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -205,9 +212,10 @@ function applyAttributePlugin(
|
|||
// Create the runtime context
|
||||
const ctx: RuntimeContext = {
|
||||
signals,
|
||||
apply,
|
||||
applyToElement,
|
||||
effect: (cb: () => void): OnRemovalFn => effect(cb),
|
||||
actions: actions,
|
||||
actions,
|
||||
removals,
|
||||
genRX: () => genRX(ctx, ...(plugin.argNames || [])),
|
||||
plugin,
|
||||
el,
|
||||
|
@ -251,16 +259,16 @@ function applyAttributePlugin(
|
|||
ctx.mods.set(camel(label), new Set(mod.map((t) => t.toLowerCase())))
|
||||
}
|
||||
|
||||
// Load the plugin and store any cleanup functions
|
||||
const cleanup = plugin.onLoad(ctx)
|
||||
if (cleanup) {
|
||||
let elTracking = removals.get(el)
|
||||
// Load the plugin
|
||||
const cleanup = plugin.onLoad(ctx) ?? (() => {})
|
||||
|
||||
// Store the cleanup function
|
||||
let elTracking = removals.get(el.id)
|
||||
if (!elTracking) {
|
||||
elTracking = new Map()
|
||||
removals.set(el, elTracking)
|
||||
removals.set(el.id, elTracking)
|
||||
}
|
||||
elTracking.set(hash, cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
function genRX(
|
||||
|
|
|
@ -81,7 +81,8 @@ export type InitContext = {
|
|||
signals: SignalsRoot
|
||||
effect: (fn: EffectFn) => OnRemovalFn
|
||||
actions: Readonly<ActionPlugins>
|
||||
apply: (el?: HTMLorSVGElement) => void
|
||||
removals: Map<string, Map<number, OnRemovalFn>>
|
||||
applyToElement: (el: HTMLorSVGElement) => void
|
||||
}
|
||||
|
||||
export type HTMLorSVGElement = Element & (HTMLElement | SVGElement)
|
||||
|
|
|
@ -11,10 +11,12 @@ import {
|
|||
} from '../../../../engine/consts'
|
||||
import { initErr } from '../../../../engine/errors'
|
||||
import {
|
||||
type HTMLorSVGElement,
|
||||
type InitContext,
|
||||
PluginType,
|
||||
type WatcherPlugin,
|
||||
} from '../../../../engine/types'
|
||||
import { attrHash, elUniqId, walkDOM } from '../../../../utils/dom'
|
||||
import { isBoolString } from '../../../../utils/text'
|
||||
import {
|
||||
docWithViewTransitionAPI,
|
||||
|
@ -85,7 +87,25 @@ function applyToTargets(
|
|||
const modifiedTarget = initialTarget
|
||||
switch (mergeMode) {
|
||||
case FragmentMergeModes.Morph: {
|
||||
Idiomorph.morph(modifiedTarget, fragment.cloneNode(true))
|
||||
const fragmentWithIDs = fragment.cloneNode(true) as HTMLorSVGElement
|
||||
walkDOM(fragmentWithIDs, (el) => {
|
||||
if (!el.id?.length && Object.keys(el.dataset).length) {
|
||||
el.id = elUniqId(el)
|
||||
}
|
||||
// Rehash the cleanup functions for this element to ensure that plugins are cleaned up and reapplied after merging.
|
||||
const elTracking = ctx.removals.get(el.id)
|
||||
if (elTracking) {
|
||||
const newElTracking = new Map()
|
||||
for (const [key, cleanup] of elTracking) {
|
||||
const newKey = attrHash(key, key)
|
||||
newElTracking.set(newKey, cleanup)
|
||||
elTracking.delete(key)
|
||||
}
|
||||
ctx.removals.set(el.id, newElTracking)
|
||||
}
|
||||
})
|
||||
|
||||
Idiomorph.morph(modifiedTarget, fragmentWithIDs)
|
||||
break
|
||||
}
|
||||
case FragmentMergeModes.Inner:
|
||||
|
|
|
@ -128,7 +128,7 @@ export const Bind: AttributePlugin = {
|
|||
|
||||
const current = signals.value(signalName)
|
||||
const input = (el as HTMLInputElement) || (el as HTMLElement)
|
||||
const value = input.value || input.getAttribute('value') || ''
|
||||
const value = input.value || ''
|
||||
|
||||
if (isCheckbox) {
|
||||
const checked = input.checked || input.getAttribute('checked') === 'true'
|
||||
|
@ -190,7 +190,7 @@ export const Bind: AttributePlugin = {
|
|||
for (const event of updateEvents) {
|
||||
el.addEventListener(event, el2sig)
|
||||
}
|
||||
const elSigClean = effect(() => setFromSignal())
|
||||
|
||||
/*
|
||||
* The signal value needs to be updated after the "pageshow" event.
|
||||
* Sometimes, the browser might populate inputs with previous values
|
||||
|
@ -200,11 +200,14 @@ export const Bind: AttributePlugin = {
|
|||
* https://web.dev/articles/bfcache
|
||||
*/
|
||||
const onPageshow = (ev: PageTransitionEvent) => {
|
||||
if (!ev.persisted) return
|
||||
if (ev.persisted) {
|
||||
el2sig()
|
||||
}
|
||||
}
|
||||
window.addEventListener("pageshow", onPageshow)
|
||||
|
||||
const elSigClean = effect(() => setFromSignal())
|
||||
|
||||
return () => {
|
||||
elSigClean()
|
||||
for (const event of updateEvents) {
|
||||
|
|
|
@ -51,7 +51,7 @@ export function elUniqId(el: Element) {
|
|||
return hash.string
|
||||
}
|
||||
|
||||
export function attrHash(key: string, val: string) {
|
||||
export function attrHash(key: number | string, val: number | string) {
|
||||
return new Hash().with(key).with(val).value
|
||||
}
|
||||
|
||||
|
|
|
@ -7,8 +7,8 @@ import "time"
|
|||
const (
|
||||
DatastarKey = "datastar"
|
||||
Version = "1.0.0-beta.8"
|
||||
VersionClientByteSize = 39499
|
||||
VersionClientByteSizeGzip = 14651
|
||||
VersionClientByteSize = 39747
|
||||
VersionClientByteSizeGzip = 14773
|
||||
|
||||
//region Default durations
|
||||
|
||||
|
|
|
@ -40,7 +40,8 @@ func setupTests(ctx context.Context, router chi.Router) (err error) {
|
|||
{ID: "key_casing"},
|
||||
{ID: "local_signals"},
|
||||
{ID: "merge_fragment"},
|
||||
{ID: "merge_fragment_signal"},
|
||||
{ID: "merge_fragment_on_load"},
|
||||
{ID: "merge_fragment_signals"},
|
||||
{ID: "merge_fragment_whitespace"},
|
||||
{ID: "on_load"},
|
||||
{ID: "radio_input"},
|
||||
|
@ -104,8 +105,9 @@ func setupTests(ctx context.Context, router chi.Router) (err error) {
|
|||
|
||||
if err := errors.Join(
|
||||
setupTestsMergeFragment(testsRouter),
|
||||
setupTestsMergeFragmentOnLoad(testsRouter),
|
||||
setupTestsMergeFragmentSignals(testsRouter),
|
||||
setupTestsMergeFragmentWhitespace(testsRouter),
|
||||
setupTestsMergeFragmentSignal(testsRouter),
|
||||
setupTestsOnLoad(testsRouter),
|
||||
setupTestsRemoveFragment(testsRouter),
|
||||
); err != nil {
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
datastar "github.com/starfederation/datastar/sdk/go"
|
||||
)
|
||||
|
||||
func setupTestsMergeFragmentOnLoad(testsRouter chi.Router) error {
|
||||
|
||||
testsRouter.Get("/merge_fragment_on_load/data", func(w http.ResponseWriter, r *http.Request) {
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.MergeFragments(`<div id="content" data-on-load="$result = 1"></div>`)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
datastar "github.com/starfederation/datastar/sdk/go"
|
||||
)
|
||||
|
||||
func setupTestsMergeFragmentSignal(testsRouter chi.Router) error {
|
||||
|
||||
testsRouter.Get("/merge_fragment_signal/data", func(w http.ResponseWriter, r *http.Request) {
|
||||
sse := datastar.NewSSE(w, r)
|
||||
c := mergeFragmentSignalTest()
|
||||
sse.MergeFragmentTempl(c)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package site
|
||||
|
||||
templ mergeFragmentSignalTest() {
|
||||
<div id="content" data-signals-result="1">
|
||||
<button data-on-click="@get('/tests/merge_fragment_signal/data')" class="btn">Merge</button>
|
||||
</div>
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
datastar "github.com/starfederation/datastar/sdk/go"
|
||||
)
|
||||
|
||||
func setupTestsMergeFragmentSignals(testsRouter chi.Router) error {
|
||||
|
||||
testsRouter.Get("/merge_fragment_signals/data", func(w http.ResponseWriter, r *http.Request) {
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.MergeFragments(`<div id="content" data-signals-result="1"></div>
|
||||
}`)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package smoketests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnitMergeFragmentOnLoad(t *testing.T) {
|
||||
setupPageTestOnClick(t, "tests/merge_fragment_on_load")
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package smoketests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnitMergeFragmentSignal(t *testing.T) {
|
||||
setupPageTestOnClick(t, "tests/merge_fragment_signal")
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package smoketests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnitMergeFragmentSignals(t *testing.T) {
|
||||
setupPageTestOnClick(t, "tests/merge_fragment_signals")
|
||||
}
|
|
@ -29,7 +29,7 @@ func TestExamplePersist(t *testing.T) {
|
|||
|
||||
page.MustWaitIdle()
|
||||
|
||||
input := page.MustElement("#keyInput")
|
||||
input := page.MustElement("#keyInput1")
|
||||
|
||||
revisedExpected := "This is a test"
|
||||
input.MustInput(revisedExpected)
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
## Demo
|
||||
|
||||
<div data-signals="{namespace: {test1: 'foo', test2: 'bar', test3: 'baz'}}" data-persist-foo="namespace.test1 namespace.test3">
|
||||
<input id="keyInput" class="input input-bordered" data-bind="namespace.test1"/>
|
||||
<input id="keyInput1" data-bind="namespace.test1" class="input input-bordered" />
|
||||
<br>
|
||||
<input id="keyInput" class="input input-bordered" data-bind="namespace.test2"/>
|
||||
<input data-bind="namespace.test2" class="input input-bordered" />
|
||||
<br>
|
||||
<input id="keyInput" class="input input-bordered" data-bind="namespace.test3"/>
|
||||
<input data-bind="namespace.test3" class="input input-bordered" />
|
||||
<pre data-text="ctx.signals.JSON()">Replace me</pre>
|
||||
</div>
|
||||
|
||||
|
@ -16,9 +16,9 @@
|
|||
data-signals="{namespace: {test1: 'foo', test2: 'bar', test3: 'baz'}}"
|
||||
data-persist-foo="namespace.test1 namespace.test3"
|
||||
>
|
||||
<input class="input input-bordered" data-bind="namespace.test1" />
|
||||
<input class="input input-bordered" data-bind="namespace.test2" />
|
||||
<input class="input input-bordered" data-bind="namespace.test3" />
|
||||
<input data-bind="namespace.test1" />
|
||||
<input data-bind="namespace.test2" />
|
||||
<input data-bind="namespace.test3" />
|
||||
<pre data-text="ctx.signals.JSON()">Replace me</pre>
|
||||
</div>
|
||||
```
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
# Merge Fregment Containing On Event
|
||||
|
||||
Tests that merging a fragment containing an `on` event works.
|
||||
|
||||
<div>
|
||||
<div id="content" data-signals-hidden="false" data-show="!$hidden"><button data-on-click="$hidden = true" data-show="!$hidden" class="btn">Hide</button><input data-bind-name class="input input-bordered" /><button data-on-click="@get('/tests/merge_fragment_containing_on_event/data')" class="btn">Merge</button></div>
|
||||
<hr />
|
||||
<button id="clickable" data-on-click="@get('/tests/merge_fragment_containing_on_event/data')" class="btn">Merge</button>
|
||||
<pre data-text="ctx.signals.JSON()"></pre>
|
||||
</div>
|
|
@ -0,0 +1,13 @@
|
|||
# Merge Fregment On Load
|
||||
|
||||
Tests that merging a fragment containing `data-on-load` works.
|
||||
|
||||
<div>
|
||||
<div id="content" data-signals-result="0" data-on-load="$result = 0"></div>
|
||||
<button id="clickable" data-on-click="@get('/tests/merge_fragment_on_load/data')" class="btn">Merge</button>
|
||||
<hr />
|
||||
Result:
|
||||
<code id="result" data-text="$result"></code>
|
||||
<hr />
|
||||
Expected result on click: <code>1</code>
|
||||
</div>
|
|
@ -1,12 +0,0 @@
|
|||
# Merge Fregment Signal
|
||||
|
||||
Tests that merging a fragment containing `data-signals-*` works.
|
||||
|
||||
<div>
|
||||
<div id="content" data-signals-result="0"><button id="clickable" data-on-click="@get('/tests/merge_fragment_signal/data')" class="btn">Merge</button></div>
|
||||
<hr />
|
||||
Result:
|
||||
<code id="result" data-text="$result"></code>
|
||||
<hr />
|
||||
Expected result on click: <code>1</code>
|
||||
</div>
|
|
@ -0,0 +1,13 @@
|
|||
# Merge Fregment Signals
|
||||
|
||||
Tests that merging a fragment containing `data-signals-*` works.
|
||||
|
||||
<div>
|
||||
<div id="content" data-signals-result="0"></div>
|
||||
<button id="clickable" data-on-click="@get('/tests/merge_fragment_signals/data')" class="btn">Merge</button>
|
||||
<hr />
|
||||
Result:
|
||||
<code id="result" data-text="$result"></code>
|
||||
<hr />
|
||||
Expected result on click: <code>1</code>
|
||||
</div>
|
Loading…
Reference in New Issue