Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
74e84c0
Fix web history sync: serialize updates, await traversals, replace-vs…
isaac-udy Jun 10, 2026
fb9dd67
WebHistoryPlugin: isolate per-event failures in the sync processor
isaac-udy Jun 11, 2026
635ffdd
WebHistoryPlugin: restore from URL when an entry's state can't decode
isaac-udy Jun 11, 2026
5a1eac2
Add BoxedValueClassSerializer for polymorphic value classes
isaac-udy Jun 11, 2026
d06969c
WebHistoryPlugin: verify serialized history state round-trips at writ…
isaac-udy Jun 11, 2026
3369461
WebHistoryPlugin: strip metadata from unverifiable history states + d…
isaac-udy Jun 11, 2026
b607ea8
Fix discriminator leak corrupting Instance.metadata in history state
isaac-udy Jun 11, 2026
5b321b5
Keep ALL_JSON_OBJECTS; route history serialization through the tree e…
isaac-udy Jun 11, 2026
ab66421
Pin savedstate round-trip for instances with value-class key fields
isaac-udy Jun 11, 2026
f4af4ef
Switch jsonConfiguration to POLYMORPHIC discriminator mode
isaac-udy Jun 11, 2026
0e50c9c
Changelog for web history sync rewrite + POLYMORPHIC discriminator mode
isaac-udy Jun 11, 2026
278fba5
Link upstream kotlinx issue #3022 from discriminator-mode comments
isaac-udy Jun 11, 2026
c66ee05
Move web-history/serialization changelog entries to 3.0.0-beta03
isaac-udy Jun 11, 2026
316002d
Move SavedStateSerializationTests to desktopTest
isaac-udy Jun 11, 2026
8636a02
Revert "Move SavedStateSerializationTests to desktopTest"
isaac-udy Jun 11, 2026
4a100be
Run framework-touching host tests under Robolectric
isaac-udy Jun 11, 2026
5214db3
CI: cancel superseded PR runs; release: changelog-driven release notes
isaac-udy Jun 11, 2026
6233e25
Changelog: standing Unreleased header for the release automation
isaac-udy Jun 11, 2026
3816144
Release: drop the optional notes input — the changelog is the single …
isaac-udy Jun 11, 2026
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ on:
- main
workflow_dispatch:

# Superseded PR runs are wasted compute: when a new push lands on a PR, cancel
# the in-flight run for the previous commit. Pushes to main are never
# cancelled — every main commit keeps its complete CI record for bisecting.
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
ci-linux:
name: CI / Linux (Android, Desktop, wasmJs)
Expand Down
43 changes: 21 additions & 22 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,26 @@ on:
description: 'Version Name'
required: true
default: ''
changes:
description: 'Release notes'
required: true
default: ''
jobs:
release:
name: Release
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
# add-and-commit pushes the release commit using the checkout
# credentials, so check out with the publish token.
token: ${{ secrets.PUBLISH_GITHUB_TOKEN }}

- name: Set up JDK 21
uses: actions/setup-java@v3.9.0
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 21

- name: Setup gradle
uses: gradle/gradle-build-action@v2
uses: gradle/actions/setup-gradle@v4

- name: Install gpg secret key
run: cat <(echo -e "${{ secrets.PUBLISH_SIGNING_KEY_LITERAL }}") | gpg --batch --import
Expand All @@ -36,8 +36,13 @@ jobs:
- name: Export gpg secret key
run: sudo gpg --export-secret-keys --pinentry-mode loopback --passphrase=${{ secrets.PUBLISH_SIGNING_KEY_PASSWORD }} ${{ secrets.PUBLISH_SIGNING_KEY_ID }} > ${{ secrets.PUBLISH_SIGNING_KEY_LOCATION }}

- name: Update Version Name
run: ./gradlew updateVersion -PversionName=${{ github.event.inputs.versionname }}
# Stamps version.properties AND the changelog: the standing
# "## Unreleased" section becomes "## <version> (<date>)", a fresh empty
# "## Unreleased" is inserted above it, and the released section's body
# is written to build/release-notes.md for the Create Release step.
# Fails (before writing anything) if the Unreleased section is empty.
- name: Update Version Name + Changelog
run: ./gradlew updateVersion -PversionName=${{ inputs.versionname }}

- name: Publish Release
env:
Expand All @@ -51,21 +56,15 @@ jobs:
run: ./gradlew publishAllPublicationsToMavenCentralRepository -PmavenCentralUsername="${{ secrets.PUBLISH_SONATYPE_USER }}" -PmavenCentralPassword="${{ secrets.PUBLISH_SONATYPE_PASSWORD }}" --no-parallel --stacktrace --continue --exclude-task :enro-common:publishJsPublicationToMavenCentralRepository

- name: Update Repo
uses: EndBug/add-and-commit@v5
env:
GITHUB_TOKEN: ${{ secrets.PUBLISH_GITHUB_TOKEN }}
uses: EndBug/add-and-commit@v9
with:
add: "./version.properties"
message: ${{ format('Released {0}', github.event.inputs.versionname) }}
add: |
version.properties
CHANGELOG.md
message: ${{ format('Released {0}', inputs.versionname) }}
push: true

- name: Create Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.PUBLISH_GITHUB_TOKEN }}
with:
tag_name: ${{ github.event.inputs.versionname }}
release_name: Release ${{ github.event.inputs.versionname }}
body: ${{ github.event.inputs.changes }}
draft: false
prerelease: false
GH_TOKEN: ${{ secrets.PUBLISH_GITHUB_TOKEN }}
run: gh release create "${{ inputs.versionname }}" --title "Release ${{ inputs.versionname }}" --notes-file build/release-notes.md
39 changes: 38 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,43 @@
# Changelog

## 3.0.0-beta02 (Unreleased)
## Unreleased

### Web browser history (WasmJS)

* Rewrote `WebHistoryPlugin`'s history synchronisation. Updates are now
processed on a serial queue instead of being dropped while a sync was in
flight (the cause of a single browser back traversing multiple screens),
self-initiated `history.go()` traversals await and consume their popstate
echo instead of racing a `delay(1)` (the cause of double-pops), and
full-backstack replacements (e.g. a loading gate resolving to home, or a
section switch) now `replaceState` instead of `pushState`, so transient
screens can no longer survive as browser back targets.
* History entries whose recorded state can't be decoded (e.g. written by an
older build of an app — tab history survives deploys) now restore through
the entry's URL via the `@NavigationPath` bindings instead of doing
nothing, and self-heal the entry's stored state.
* Serialized history state is verified to round-trip at write time; a state
that can't restore is a hard error rather than a silently broken back
button later.

### Serialization

* `EnroController.jsonConfiguration` now uses kotlinx's default
(`POLYMORPHIC`) class-discriminator mode instead of `ALL_JSON_OBJECTS`.
With kotlinx 1.11, `ALL_JSON_OBJECTS` breaks both Json encoders for
realistic `NavigationKey` shapes under polymorphic dispatch: the streaming
encoder leaks a value-class field's deferred discriminator into the
next-opened object (corrupting `Instance.metadata` so persisted state
could not be decoded) and emits invalid JSON for collection fields, while
the tree encoder crashes on collection fields. `POLYMORPHIC` mode writes
discriminators exactly where polymorphic deserialization reads them and
handles all of these shapes correctly. `HistoryStateSerializationTests`
documents the upstream kotlinx failures
([Kotlin/kotlinx.serialization#3022](https://github.com/Kotlin/kotlinx.serialization/issues/3022))
so a fixed kotlinx release is detected. SavedState serialization (`ALL_OBJECTS`, androidx encoder) is
unaffected and covered by `SavedStateSerializationTests`.

## 3.0.0-beta02

### Predictive back

Expand Down
38 changes: 38 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,44 @@ tasks.register("updateVersion") {
error("The versionName '$versionName' is the current versionName")
}

// ------------------------------------------------------------------
// Changelog: changes stack under a standing "## Unreleased" header.
// Releasing stamps that header with the version (and date), inserts a
// fresh empty "## Unreleased" above it, and writes the released
// section's body to build/release-notes.md for the release workflow
// to attach to the GitHub release. All validation happens before any
// file is written, so a failed release leaves the repo untouched.
// ------------------------------------------------------------------
val changelogFile = rootProject.file("CHANGELOG.md")
val changelog = changelogFile.readText()
val unreleasedHeader = "## Unreleased"
val headerIndex = changelog.indexOf(unreleasedHeader)
if (headerIndex == -1) {
error("CHANGELOG.md must contain an '## Unreleased' section to release from")
}
val bodyStart = headerIndex + unreleasedHeader.length
val nextHeaderIndex = changelog.indexOf("\n## ", bodyStart)
.let { if (it == -1) changelog.length else it }
val releaseNotesBody = changelog.substring(bodyStart, nextHeaderIndex).trim()
if (releaseNotesBody.isEmpty()) {
error(
"The 'Unreleased' section of CHANGELOG.md is empty — " +
"add release notes before releasing $versionName"
)
}

val releaseNotesFile = rootProject.layout.buildDirectory.file("release-notes.md").get().asFile
releaseNotesFile.parentFile.mkdirs()
releaseNotesFile.writeText(releaseNotesBody + "\n")

val releaseDate = java.time.LocalDate.now()
changelogFile.writeText(
changelog.replaceFirst(
unreleasedHeader,
"## Unreleased\n\n## $versionName ($releaseDate)",
)
)

versionPropertiesFile.writeText("versionName=$versionName\nversionCode=$versionCode")
}
}
Expand Down
10 changes: 9 additions & 1 deletion enro-runtime/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ kotlin {
// Return default values for unmocked Android framework methods
// rather than throwing "Method ... not mocked" — defensive against
// any test path that brushes a stub from android.jar (e.g.
// savedstate's Bundle).
// savedstate's Bundle). Tests that genuinely exercise framework
// classes should extend dev.enro.test.platform.RobolectricHostTest
// to run under Robolectric on this target instead of the stubs.
isReturnDefaultValues = true
isIncludeAndroidResources = true
}
}
}
Expand Down Expand Up @@ -57,6 +60,11 @@ kotlin {
implementation(project(":enro-test"))
implementation(libs.compose.uiTest)
}
val androidHostTest by getting {
dependencies {
implementation(libs.testing.robolectric)
}
}
androidMain.dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package dev.enro.test.platform

import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
actual abstract class RobolectricHostTest actual constructor()
9 changes: 9 additions & 0 deletions enro-runtime/src/commonMain/kotlin/dev/enro/EnroController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ public class EnroController {
return instance ?: error("EnroController has not been installed")
}

/**
* The controller's Json configuration, including all registered
* serializers. Uses the default (POLYMORPHIC) class-discriminator
* mode — `ALL_JSON_OBJECTS` is incompatible with kotlinx 1.11 for
* keys containing value-class or collection fields
* (https://github.com/Kotlin/kotlinx.serialization/issues/3022); see
* the note on SerializerRepository.jsonConfiguration and
* HistoryStateSerializationTests in enro-runtime.
*/
public val jsonConfiguration: Json get() {
val instance = requireInstance()
return instance.serializers.jsonConfiguration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,20 @@ internal class SerializerRepository {
var jsonConfiguration: Json =
Json {
serializersModule = this@SerializerRepository.serializersModule
classDiscriminatorMode = kotlinx.serialization.json.ClassDiscriminatorMode.ALL_JSON_OBJECTS
// Deliberately the default (POLYMORPHIC) discriminator mode.
// ALL_JSON_OBJECTS is unusable with kotlinx 1.11 for realistic
// NavigationKey shapes: under polymorphic dispatch, the STREAMING
// encoder leaks a value-class field's deferred discriminator into
// the next-opened object (corrupting Instance.metadata), and emits
// INVALID JSON for collection fields (a "type" key:value pair
// inside an array); the TREE encoder crashes outright
// (NumberFormatException) on collection fields. POLYMORPHIC mode
// writes discriminators exactly where polymorphic deserialization
// reads them (Instance.key, metadata values) and handles all of
// these shapes correctly. See HistoryStateSerializationTests,
// which also documents the ALL_JSON_OBJECTS failures so a kotlinx
// upgrade that fixes them is detected. Upstream:
// https://github.com/Kotlin/kotlinx.serialization/issues/3022
ignoreUnknownKeys = true
}
private set
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package dev.enro.serialization

import dev.enro.NavigationKey
import dev.enro.asInstance
import dev.enro.controller.repository.SerializerRepository
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.PolymorphicSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.ClassDiscriminatorMode
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import kotlin.jvm.JvmInline
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

@Serializable
@JvmInline
internal value class HistoryTestId(val value: String)

@Serializable
internal enum class HistoryTestType { NPC, LOCATION }

@Serializable
internal data class HistoryTestKey(
val id: HistoryTestId,
val name: String = "",
val types: Set<HistoryTestType> = emptySet(),
val excludeIds: Set<HistoryTestId> = emptySet(),
) : NavigationKey

/**
* Pins the serialization of persisted navigation state (browser history,
* deep-link tooling) under the controller's json configuration, which uses
* the default (POLYMORPHIC) class-discriminator mode.
*
* `ClassDiscriminatorMode.ALL_JSON_OBJECTS` cannot be used: with kotlinx
* 1.11, realistic NavigationKey shapes break BOTH encoders under polymorphic
* dispatch —
*
* * STREAMING: a value-class field's discriminator write is deferred until
* the next `beginStructure`, which an inline field never opens, so the
* pending discriminator leaks into the next-opened object
* (`Instance.metadata` in practice) and the output fails to decode with
* "Expected JsonObject, but had JsonLiteral". Collection fields produce
* outright INVALID JSON (a `"type"` key:value pair inside an array).
* * TREE (`encodeToJsonElement`): collection fields crash the encoder with
* `NumberFormatException: For input string: "type"` (the deferred
* discriminator is applied inside a list context, where the tag is parsed
* as an array index).
*
* The documenting tests below assert those failures still exist: when one
* starts failing, kotlinx has fixed the corresponding bug and the
* POLYMORPHIC-mode constraint can be revisited. Upstream issue:
* https://github.com/Kotlin/kotlinx.serialization/issues/3022
*/
class HistoryStateSerializationTests {

private fun repository(): SerializerRepository {
return SerializerRepository().apply {
registerSerializersModule(
SerializersModule {
polymorphic(NavigationKey::class) {
subclass(HistoryTestKey.serializer())
}
}
)
}
}

private val instanceSerializer =
NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class))

private val fullShapeKey = HistoryTestKey(
id = HistoryTestId("abc-123"),
name = "name",
types = setOf(HistoryTestType.NPC, HistoryTestType.LOCATION),
excludeIds = setOf(HistoryTestId("excluded")),
)

@Test
fun instanceWithValueClassAndCollectionFieldsRoundTrips() {
val json = repository().jsonConfiguration
val instance = fullShapeKey.asInstance()

val encoded = json.encodeToString(instanceSerializer, instance)
val decoded = json.decodeFromString(instanceSerializer, encoded)

assertEquals(instance.id, decoded.id)
assertEquals(instance.key, decoded.key)
}

@OptIn(ExperimentalSerializationApi::class)
private fun allJsonObjectsJson(): Json = Json(from = repository().jsonConfiguration) {
classDiscriminatorMode = ClassDiscriminatorMode.ALL_JSON_OBJECTS
}

/**
* Documents the upstream kotlinx streaming-encoder bug. If this test
* FAILS, kotlinx has fixed the deferred-discriminator leak for inline
* fields and ALL_JSON_OBJECTS may be viable again (check the other
* documenting tests too).
*/
@Test
fun allJsonObjectsStreamingEncoderLeaksValueClassDiscriminator() {
val json = allJsonObjectsJson()
val instance = HistoryTestKey(id = HistoryTestId("abc-123")).asInstance()

val encoded = json.encodeToString(instanceSerializer, instance)

assertTrue(
encoded.contains("\"metadata\":{\"type\":\"dev.enro.serialization.HistoryTestId\""),
"kotlinx appears to have fixed the streaming-encoder discriminator leak — " +
"ALL_JSON_OBJECTS may be viable again. Encoded: $encoded",
)
}

/**
* Documents the upstream kotlinx tree-encoder bug. If this test FAILS,
* kotlinx has fixed the collection-field discriminator crash and
* ALL_JSON_OBJECTS may be viable again (check the other documenting
* tests too).
*/
@Test
fun allJsonObjectsTreeEncoderFailsOnCollectionFields() {
val json = allJsonObjectsJson()
val instance = fullShapeKey.asInstance()

val result = runCatching {
json.encodeToJsonElement(instanceSerializer, instance)
}

assertTrue(
result.isFailure,
"kotlinx appears to have fixed the tree-encoder collection-field crash — " +
"ALL_JSON_OBJECTS may be viable again. Encoded: ${result.getOrNull()}",
)
}
}
Loading
Loading