Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
46f51e4
chore: move @portabletext/markdown to its own repository
christianhg Nov 12, 2026
73444cf
chore: merge main into next
christianhg Nov 12, 2026
bc28c8b
chore(deps): update dependency @portabletext/markdown to ^2.9.4
renovate[bot] Nov 12, 2026
953740a
chore: merge main into next
christianhg Nov 12, 2026
5c122ec
test: capture a multi-span block split as a wire-catalogue scenario
christianhg Sep 1, 2026
af32fac
Merge branch 'main' into next
christianhg Nov 12, 2026
668eaa6
test: check each Japanese word against how Gboard committed it
christianhg Nov 12, 2026
c072308
Merge branch 'main' into next
christianhg Nov 12, 2026
c9d9e33
fix: keep the `children` field of block objects
christianhg Nov 11, 2026
d3b8bdc
Merge branch 'main' into next
christianhg Nov 12, 2026
25e1234
feat(thumbjar): record each input event's target ranges, selection an…
christianhg Nov 12, 2026
32be206
refactor(thumbjar): read the driver's field selection from the page's…
christianhg Nov 12, 2026
12ce7b7
Merge branch 'main' into next
christianhg Nov 12, 2026
4739e5e
feat(thumbjar): prepare and run a device test file with only Node and…
christianhg Nov 12, 2026
81ca461
docs(editor): describe how thumbjar bounds the Android test stages
christianhg Nov 12, 2026
b49b236
fix(thumbjar): bound every stage after its parent dies and keep the p…
christianhg Nov 12, 2026
f184eee
docs(editor): say which Android test stages thumbjar bounds
christianhg Nov 12, 2026
c474d5e
fix(thumbjar): end a stage whose guard is killed
christianhg Nov 12, 2026
ccb7953
docs(thumbjar): name only the host the commands have run on
christianhg Nov 12, 2026
4ca9a11
fix(thumbjar): give the prep's children the out dir and the environme…
christianhg Nov 12, 2026
5477a4d
Merge remote-tracking branch 'origin/main' into HEAD
christianhg Nov 12, 2026
03b6447
test: drop readiness waits that `createTestEditor` already does
christianhg Nov 12, 2026
abcc1e4
Merge remote-tracking branch 'origin/main' into HEAD
christianhg Nov 12, 2026
1171c7f
fix(thumbjar): cache u2.jar in the calling package's node_modules/.cache
christianhg Nov 12, 2026
6800d4a
ci(editor): restore and save u2.jar from the editor package's cache d…
christianhg Nov 12, 2026
528f81f
fix(thumbjar): use find-cache-directory and say when no cache directo…
christianhg Nov 12, 2026
48332bf
Merge remote-tracking branch 'origin/main' into HEAD
christianhg Nov 12, 2026
3f937d0
test(thumbjar): wait for every recorded stage before killing the run …
christianhg Nov 12, 2026
0d9e72c
Merge remote-tracking branch 'origin/main' into HEAD
christianhg Nov 12, 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 .changeset/keep-block-object-children.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@portabletext/editor': patch
---

fix: keep the `children` field of block objects

A block object with its own `children` field lost it when the editor took the block in. Moving the block, or deleting it and undoing, emitted patches without the field, and inserting or pasting such a block did the same. A container registered with `arrayField: 'children'` rendered empty, and its first edit emitted a patch that replaced its blocks with one empty block. Block objects now keep every field, and these containers render their blocks and follow `value` updates.
2 changes: 1 addition & 1 deletion .github/scripts/emulator-runs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1951,7 +1951,7 @@ describe('the workflow', () => {
workflow.split(` - name: ${name}\n`)[1]?.split('\n - ')[0] ?? ''
const key =
"u2-jar-${{ hashFiles('packages/thumbjar/src/device/u2-jar.json') }}"
const path = 'packages/thumbjar/.cache/u2.jar'
const path = 'packages/editor/node_modules/.cache/thumbjar/u2.jar'
expect({
order: names.slice(
names.indexOf('Restore the uiautomator2 server jar'),
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/android-ime.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,17 @@ jobs:
pnpm --filter @portabletext/editor build:android
node -e "console.log('EDITOR_VERSION', require('./packages/editor/package.json').version)"

# The jar comes from the actions cache when an earlier run saved it, under a key that changes with its
# URL and hash, see the jar cache in `packages/editor/android/README.md`. A failed restore or save is a
# miss, which downloads as before.
# The jar comes from the actions cache when an earlier run saved it, under the editor package's cache
# directory and a key that changes with its URL and hash, see the jar cache in
# `packages/editor/android/README.md`. A failed restore or save is a miss, which downloads as before.
- name: Restore the uiautomator2 server jar
id: u2-jar-restore
continue-on-error: true
timeout-minutes: 1
uses: actions/cache/restore@v6
with:
key: u2-jar-${{ hashFiles('packages/thumbjar/src/device/u2-jar.json') }}
path: packages/thumbjar/.cache/u2.jar
path: packages/editor/node_modules/.cache/thumbjar/u2.jar

# A restored jar whose hash differs is downloaded again.
- name: Fetch the uiautomator2 server jar
Expand All @@ -131,7 +131,7 @@ jobs:
uses: actions/cache/save@v6
with:
key: u2-jar-${{ hashFiles('packages/thumbjar/src/device/u2-jar.json') }}
path: packages/thumbjar/.cache/u2.jar
path: packages/editor/node_modules/.cache/thumbjar/u2.jar

# The emulator and its system image come from the actions cache when it holds the versions a fresh
# install picks today, see the SDK cache in `packages/editor/android/README.md`. A failed lookup,
Expand Down
2 changes: 1 addition & 1 deletion apps/doc-examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@portabletext/editor": "workspace:*",
"@portabletext/html": "workspace:*",
"@portabletext/keyboard-shortcuts": "workspace:*",
"@portabletext/markdown": "^2.9.3",
"@portabletext/markdown": "^2.9.4",
"@portabletext/patches": "workspace:*",
"@portabletext/plugin-character-pair-decorator": "workspace:*",
"@portabletext/plugin-decorations": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"@portabletext/editor": "workspace:*",
"@portabletext/html": "workspace:*",
"@portabletext/keyboard-shortcuts": "workspace:*",
"@portabletext/markdown": "^2.9.3",
"@portabletext/markdown": "^2.9.4",
"@portabletext/patches": "workspace:*",
"@portabletext/plugin-decorations": "workspace:*",
"@portabletext/plugin-dnd": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"src/vitest/global-setup.ts!",
"src/vitest/setup.ts!",
"src/bin.ts!",
"src/guard.ts!",
"src/prepare/*.ts!",
"src/run/*.ts!",
"package.config.ts!",
Expand Down
6 changes: 4 additions & 2 deletions packages/editor/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ The artifact also records why the emulator process exited, when it did. `host.lo

The emulator and the `google_apis_playstore` x86_64 system image come from the actions cache when it holds the versions a fresh install picks. The runner image ships platform-tools and the platform, so they are not cached. The cache key names the runner OS, the API level, the image, and the image revision and emulator version that `sdkmanager --list` reports as available at the lookup, which takes about 3 seconds. A lookup that fails, or an SDK that already has one of the two packages, skips the cache. `sdkmanager --install` never upgrades or repairs an installed package, it only checks that its directory exists, so every save carries a manifest with the cache's key and each file in the two packages with its size. A file name with a tab, a newline or a carriage return fails the manifest, and nothing is saved. After a restore, `.github/scripts/emulator-runs.mjs cache-check` deletes both packages when the manifest names another key, and a package that has a file missing, a file added or a file of another size, or that the manifest does not list, and the install downloads it as on a miss. A check that fails or times out deletes both packages. Only runs on `main` save, the nightly schedule and a manual dispatch on `main`, after a successful install, and only when the manifest was written from the installed packages. Pull requests restore `main`'s entry and save none. A failed lookup, restore or save never fails the job or changes its result. The AVD and its snapshots are never cached, so every run boots a fresh emulator.

The uiautomator2 server jar comes from the actions cache under the key `u2-jar-<hash of packages/thumbjar/src/device/u2-jar.json>`. That file pins the jar's URL and its sha256, so a new jar gets a new key. `thumbjar fetch-u2-jar` checks a restored jar's hash and downloads it again when the hash differs, and its log line says whether it downloaded the jar. After a fetch on a miss, the job saves the jar from any branch or pull request. A pull request's entry is read only by runs of that pull request, and `main`'s, which the nightly run and a manual dispatch on `main` save, by every branch. A failed restore or save never fails the job.
The uiautomator2 server jar at `packages/editor/node_modules/.cache/thumbjar/u2.jar` comes from the actions cache under the key `u2-jar-<hash of packages/thumbjar/src/device/u2-jar.json>`. That file pins the jar's URL and its sha256, so a new jar gets a new key. `thumbjar fetch-u2-jar` checks a restored jar's hash and downloads it again when the hash differs, and its log line says whether it downloaded the jar. After a fetch on a miss, the job saves the jar from any branch or pull request. A pull request's entry is read only by runs of that pull request, and `main`'s, which the nightly run and a manual dispatch on `main` save, by every branch. A failed restore or save never fails the job.

Before retrying installation, the job clears only new `PackageOperation` directories under `$ANDROID_HOME/.temp` and new package directories from that install, and the packages the cache check kept, preserving other paths present before the attempt. These hold the downloaded archive, unpacked files and installer resume metadata. It does not clear the SDK's other packages or `~/.android/cache`. If `install-start` failed before recording its snapshot, sdkmanager never ran, so the retry clears nothing and reports the missing snapshot.

### Time budget

thumbjar bounds each `typeOnDevice` and `runOnDevice` call and the device prep. `thumbjar run` runs each stage under `timeout -k`, with the test stage's cap read from `deviceTestFileSeconds` for the file's test count, and logs why when it stops one. The workflow's `timeout-minutes` are flat backstops, including for a hang inside the emulator runner action. A run a step's cap stops after boot, before its device check completes, is not retried and its result says so, see the retry above.
thumbjar bounds each `typeOnDevice` and `runOnDevice` call and the device prep. `thumbjar run` gives the cap read and device check 10 seconds with 5 to stop, the test stage `deviceTestFileSeconds` for the file's test count with 10 to stop, and the final diagnostics 60 seconds with 5 to stop. Node guards enforce the child deadlines with SIGTERM followed by SIGKILL even if the run process is killed. Prep enforces its own per-language deadline, and the server stops when its connection to the run process closes. The workflow's `timeout-minutes` are flat backstops, including for a hang inside the emulator runner action. A run a step's cap stops after boot, before its device check completes, is not retried and its result says so, see the retry above.

## Running it

Expand All @@ -77,6 +77,8 @@ cd packages/editor
pnpm exec thumbjar run tests/ja.test.ts --keyboard ja --page-dir android/dist --config vitest.android.config.ts --project device --out-dir android/out
```

The filtered fetch runs with `packages/editor` as its current working directory, so it uses `packages/editor/node_modules/.cache/thumbjar/u2.jar`.

`thumbjar run` expects a freshly booted emulator. It runs from `packages/editor`, as the workflow does, serves `android/dist/` on port 8765, prepares the device for `--keyboard` with `thumbjar prepare`, runs the one test file it is given and writes everything to `android/out/`.

Once a device is prepared and the server runs, run the tests again without the prep, from `packages/editor`:
Expand Down
58 changes: 58 additions & 0 deletions packages/editor/android/scripts/device-suites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,64 @@ describe('the Japanese device tests', () => {
})
}, 30_000)

test('passes every case when Space converts a word to kanji', async () => {
expect(
await runSuite({
root,
file: 'tests/ja.test.ts',
setupFiles: [
'scripts/fixtures/suites/typing-commits-kanji-with-space.ts',
],
}),
).toEqual({
file: 'passed',
errors: [],
tests: [
{
name: 'Scenario: Typing kyou twice into a plain contenteditable',
state: 'passed',
},
{
name: 'Scenario: Typing kyou twice into an empty block',
state: 'passed',
},
{
name: 'Scenario: Typing kyou twice after existing text',
state: 'passed',
},
],
})
}, 30_000)

test('fails every case when a tapped kanji candidate remains kana', async () => {
expect(
await runSuite({
root,
file: 'tests/ja.test.ts',
setupFiles: [
'scripts/fixtures/suites/typing-keeps-kana-after-picking-kanji.ts',
],
}),
).toEqual({
file: 'failed',
errors: [],
tests: [
{
name: 'Scenario: Typing kyou twice into a plain contenteditable',
state: 'failed',
},
{
name: 'Scenario: Typing kyou twice into an empty block',
state: 'failed',
},
{
name: 'Scenario: Typing kyou twice after existing text',
state: 'failed',
},
],
})
}, 30_000)

test('fails every case when the text is right but the page recorded no composition', async () => {
expect(
await runSuite({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {vi} from 'vitest'

vi.mock(import('thumbjar'), () => ({
typeOnDevice: (_context: unknown, {prefix}: {prefix: string}) =>
Promise.resolve({
text: `${prefix}今日今日`,
picked: ['space', '今日'],
compositionstart: 2,
nested: 0,
compositionstartPerWord: [1, 1],
}),
}))
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {vi} from 'vitest'

vi.mock(import('thumbjar'), () => ({
typeOnDevice: (_context: unknown, {prefix}: {prefix: string}) =>
Promise.resolve({
text: `${prefix}きょう今日`,
picked: ['今日', '今日'],
compositionstart: 2,
nested: 0,
compositionstartPerWord: [1, 1],
}),
}))
16 changes: 11 additions & 5 deletions packages/editor/android/tests/ja.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ test('Scenario: Typing kyou twice into a plain contenteditable', async (context)
],
})

// Gboard sometimes commits a word as kana before its conversion candidates load, and the candidates
// can load between the two words, so each word is either 今日 or きょう in every test here.
expect(typed.text).toMatch(/^(今日|きょう){2}$/)
// A word thumbjar committed with Space may stay kana or be converted by it, but a word it committed by
// tapping the `今日` candidate must read `今日`.
const firstWord = typed.picked[0] === 'space' ? '(今日|きょう)' : '今日'
const secondWord = typed.picked[1] === 'space' ? '(今日|きょう)' : '今日'
expect(typed.text).toMatch(new RegExp(`^${firstWord}${secondWord}$`))
expect({
nested: typed.nested,
compositionstartPerWord: typed.compositionstartPerWord,
Expand All @@ -32,7 +34,9 @@ test('Scenario: Typing kyou twice into an empty block', async (context) => {
],
})

expect(typed.text).toMatch(/^(今日|きょう){2}$/)
const firstWord = typed.picked[0] === 'space' ? '(今日|きょう)' : '今日'
const secondWord = typed.picked[1] === 'space' ? '(今日|きょう)' : '今日'
expect(typed.text).toMatch(new RegExp(`^${firstWord}${secondWord}$`))
expect({
nested: typed.nested,
compositionstartPerWord: typed.compositionstartPerWord,
Expand All @@ -50,7 +54,9 @@ test('Scenario: Typing kyou twice after existing text', async (context) => {
],
})

expect(typed.text).toMatch(/^x(今日|きょう){2}$/)
const firstWord = typed.picked[0] === 'space' ? '(今日|きょう)' : '今日'
const secondWord = typed.picked[1] === 'space' ? '(今日|きょう)' : '今日'
expect(typed.text).toMatch(new RegExp(`^x${firstWord}${secondWord}$`))
expect({
nested: typed.nested,
compositionstartPerWord: typed.compositionstartPerWord,
Expand Down
20 changes: 12 additions & 8 deletions packages/editor/src/editor/sync-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1267,22 +1267,26 @@ function updateBlock({
schemaTypes: context.schema,
})

// Strip `children`: they're synced by dedicated child operations below.
const {children: _children, ...blockProps} = engineBlock as unknown as Record<
string,
unknown
>
const blockIsTextBlock = isTextBlock({schema: context.schema}, engineBlock)

// A text block's `children` are synced by the child operations below. Any
// other block's `children` is an ordinary field.
const {children: _children, ...propsWithoutChildren} =
engineBlock as unknown as Record<string, unknown>

const blockProps = blockIsTextBlock ? propsWithoutChildren : engineBlock

applyNodeProperties(editorEngine, blockProps, [{_key: oldEngineBlock._key}])

// Remove properties present on the old node but absent from the new block.
// Skip children/text (structural, managed by dedicated operations).
// Skip `text`, and a text block's `children`, which the child operations
// below sync.
const oldRecord = oldEngineBlock as unknown as Record<string, unknown>
const newRecord = engineBlock as unknown as Record<string, unknown>
const removedProperties: Record<string, unknown> = {}

for (const key of Object.keys(oldRecord)) {
if (key === 'children' || key === 'text') {
if (key === 'text' || (key === 'children' && blockIsTextBlock)) {
continue
}

Expand All @@ -1299,7 +1303,7 @@ function updateBlock({

// Text block's need to have their children updated as well (setNode does not target a node's children)
if (
isTextBlock({schema: editorEngine.snapshot.context.schema}, engineBlock) &&
blockIsTextBlock &&
isTextBlock({schema: editorEngine.snapshot.context.schema}, oldEngineBlock)
) {
// Detect cases where incremental remove/insert would break due to
Expand Down
4 changes: 1 addition & 3 deletions packages/editor/src/internal-utils/values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,10 @@ export function toEngineBlock(
return {_type, _key, ...rest, children}
}

const {children: _originalChildren, ...blockObjectProps} = rest

return {
_type,
_key,
...blockObjectProps,
...rest,
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
{
"scenario": "block-split-multi-span",
"schema": {
"decorators": [
{
"name": "strong"
}
]
},
"seed": "B: fo|o [strong:bar] baz",
"actions": [
"select {caret at offset 2 on the first span}",
"send {type: 'insert.break'}"
],
"patches": [
{
"type": "diffMatchPatch",
"path": [
{
"_key": "k0"
},
"children",
{
"_key": "k1"
},
"text"
],
"value": "@@ -1,4 +1,2 @@\n fo\n-o \n",
"origin": "local"
},
{
"type": "unset",
"path": [
{
"_key": "k0"
},
"children",
{
"_key": "k2"
}
],
"origin": "local"
},
{
"type": "diffMatchPatch",
"path": [
{
"_key": "k0"
},
"children",
{
"_key": "k3"
},
"text"
],
"value": "@@ -1,4 +0,0 @@\n- baz\n",
"origin": "local"
},
{
"type": "insert",
"path": [
{
"_key": "k0"
}
],
"position": "after",
"items": [
{
"_type": "block",
"_key": "k6",
"children": [
{
"_type": "span",
"_key": "k1",
"text": "o ",
"marks": []
},
{
"_type": "span",
"_key": "k2",
"text": "bar",
"marks": [
"strong"
]
},
{
"_type": "span",
"_key": "k3",
"text": " baz",
"marks": []
}
],
"markDefs": [],
"style": "normal"
}
],
"origin": "local"
},
{
"type": "unset",
"path": [
{
"_key": "k0"
},
"children",
{
"_key": "k3"
}
],
"origin": "local"
}
],
"result": "B: fo\nB: |o [strong:bar] baz"
}
Loading
Loading