Skip to content

chore: version packages - #431

Open
blove wants to merge 1 commit into
mainfrom
changeset-release/main
Open

chore: version packages#431
blove wants to merge 1 commit into
mainfrom
changeset-release/main

Conversation

@blove

@blove blove commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.

Releases

@pretable/core@0.11.0

Minor Changes

  • Eviction: the focused cell and the selection anchor survive their rows being (#427)
    released, exactly as a selection range already does — and DOM focus never falls
    to <body>.

    The cursor. reconcileIndexedFocus re-seated to the nearest surviving row
    whenever the focused row was absent. Under eviction that silently moved the
    user's cursor: scroll away, scroll back, and focus had migrated. That rule was
    written when an absent row could only mean a deleted one. It now reads the same
    discriminator the selection does — resultMeta.window — through the same
    provenDeletedRow:

    The focused row is… Result
    evicted (absent, outside the window) cursor retained
    deleted or hidden (absent, inside it) re-seats to nearest survivor
    still loaded unchanged

    The anchor. anchor = ranges[0].start fired on visibility alone, so an
    evicted anchor migrated to the first range's start. The anchor is the fixed end
    of the next gesture — a shift-click extends straight from that address — so
    for an upward selection (anchor at the range's end) or a cmd-clicked second
    range, the following shift-click extended from the wrong end and deselected what
    the user had. It is now retained when merely evicted and reassigned only on a
    proven deletion.

    DOM focus. When the cursor's cell is unmounted — an evicted row, or an
    ordinary scroll past the virtualization window — focus is parked on the grid's
    scroll viewport rather than being dropped to <body>, so the keyboard keeps
    working and a screen reader stays inside the grid. The cell takes focus back the
    moment its row is rendered again, and arrow keys resume from there rather than
    from wherever the viewport is parked. Proven in a real browser
    (apps/bench/tests/eviction.spec.ts), with a kill switch that strips the window
    and asserts the cursor is lost — jsdom has no opinion about where focus goes
    when its element unmounts.

    Local mode — a grid with no window — is unchanged in every branch.

  • Eviction, finished: an arrow key no longer loses an evicted cursor, and one (#446)
    deleted row no longer takes a whole selection with it.

    The keyboard. The cursor survives its row being released — but
    moveIndexedFocus reconciled two-argument, so it could not tell an evicted row
    from a deleted one and dropped the cursor on the very next keystroke. That is
    precisely the state a user is in: they scrolled away from the cell, then pressed
    a key. The eviction context is now threaded through the move and through the
    store's moveFocus.

    A row-axis move from a cursor whose row is not loaded is refused — the
    cursor holds where the user left it — while the column axis still answers,
    because it never needed the row. The alternatives were both worse: jumping to
    the nearest loaded row teleports the cursor across however many rows were
    released, and moving to the adjacent dataset position cannot be expressed at
    all, because a focus ref addresses a cursor by row identity and the engine
    cannot name a row it has never loaded. A positional cursor that requests its own
    row is a real feature and a product decision; it is deliberately not smuggled in
    here.

    The selection. Retention was per range: reconciliation dropped a range as
    soon as either endpoint was proven deleted, and collapsed it onto the survivor
    when one endpoint was still loaded. So a range whose start was genuinely deleted
    while its end was merely evicted was discarded whole — an 81-row selection
    reporting 0, or 1, with 80 of those rows still loaded and painted.

    The spec states the rule per row, and datasetRowSpan is what makes that
    expressible: the proven-deleted rows prune and the span around them narrows.

    The range's endpoints Before Now
    one deleted, one evicted dropped whole narrows past the deleted row
    one deleted, one loaded collapsed onto the survivor narrows past the deleted row
    both evicted, neither provable kept whole unchanged
    every row in the span proven deleted dropped unchanged

    A deletion shifts everything after it down one, so whichever end was removed the
    survivors land on lo … hi - 1. The deleted endpoint's identity is replaced
    by the row that now holds the narrowed boundary, so no deleted row id lingers in
    a live selection — where the anchor reassignment would hand it straight back.
    Fail-closed throughout: a range with no readable span, an emptied span, or a
    narrowed boundary that is not loaded is still dropped exactly as before.

    Local mode — a grid with no window — is byte-for-byte unchanged in every branch.

Patch Changes

  • processing: { filter: "external" } now stops the engine applying (#447)
    query.filters, without changing anything the grid reports.

    Declaring external filter authority used to change nothing about which rows were
    drawn: the engine went on re-applying the published filters to whatever rows it
    was handed. That is idempotent while the rows and the query agree — the server
    answered the same query, so re-selecting changed nothing — which is why it went
    unnoticed. It stops being idempotent the moment they disagree, and the lifecycle
    guarantees they will: during dataState.phase === "stale" the loaded rows answer
    the PREVIOUS query while the grid already holds the NEW one, so the engine
    filtered the old window by the new filter and the reader watched rows vanish and
    return. The same mechanism was reproducible in the error phase — same rows,
    same failed request, contains "fail" emptying the body while notContains "fail" kept every row.

    Under external authority the consumer owns which records exist, so re-applying
    the query was the grid overruling the authority it had just been told it does not
    have.

    Nothing that is REPORTED moves. The funnel still shows the active filter,
    onQueryChange still publishes it, the query in the snapshot is byte-for-byte
    what it was, and aria-sort is untouched. A filter naming a column that does not
    exist is still rejected. Only the record selection stops.

    Two consequences worth naming:

    • Group aggregates fold everything loaded. With aggregateFilteredRows, the
      filtered population under suppression is the whole loaded window, because the
      server already chose it. Groups whose rows all failed the local filter now
      appear with their totals instead of disappearing.
    • Sort is deliberately unchanged. sort: "external" still lets the engine
      order the rows it was given; only filtering is suppressed.

    Rows mode only. A consumer who supplies their own model through model= already
    decides what goes into the query and can omit the filters themselves, so the
    surface never moves that model's authority.

  • The server-controlled data surface is no longer marked experimental. External (#426)
    filter/sort authority (PretableProcessingAuthority, PretableProcessingOptions),
    the dataState lifecycle (PretableDataState, PretableBodyStateKind) and
    result metadata (PretableMatchingTotal, PretableResultMeta) shipped across
    five releases, are locked behind the API-surface gate and carry e2e coverage, so
    the @experimental hedge on their TSDoc has been dropped. Their types and
    behavior are unchanged.

@pretable/react@0.11.0

Minor Changes

  • Eviction: the focused cell and the selection anchor survive their rows being (#427)
    released, exactly as a selection range already does — and DOM focus never falls
    to <body>.

    The cursor. reconcileIndexedFocus re-seated to the nearest surviving row
    whenever the focused row was absent. Under eviction that silently moved the
    user's cursor: scroll away, scroll back, and focus had migrated. That rule was
    written when an absent row could only mean a deleted one. It now reads the same
    discriminator the selection does — resultMeta.window — through the same
    provenDeletedRow:

    The focused row is… Result
    evicted (absent, outside the window) cursor retained
    deleted or hidden (absent, inside it) re-seats to nearest survivor
    still loaded unchanged

    The anchor. anchor = ranges[0].start fired on visibility alone, so an
    evicted anchor migrated to the first range's start. The anchor is the fixed end
    of the next gesture — a shift-click extends straight from that address — so
    for an upward selection (anchor at the range's end) or a cmd-clicked second
    range, the following shift-click extended from the wrong end and deselected what
    the user had. It is now retained when merely evicted and reassigned only on a
    proven deletion.

    DOM focus. When the cursor's cell is unmounted — an evicted row, or an
    ordinary scroll past the virtualization window — focus is parked on the grid's
    scroll viewport rather than being dropped to <body>, so the keyboard keeps
    working and a screen reader stays inside the grid. The cell takes focus back the
    moment its row is rendered again, and arrow keys resume from there rather than
    from wherever the viewport is parked. Proven in a real browser
    (apps/bench/tests/eviction.spec.ts), with a kill switch that strips the window
    and asserts the cursor is lost — jsdom has no opinion about where focus goes
    when its element unmounts.

    Local mode — a grid with no window — is unchanged in every branch.

  • Eviction, finished: an arrow key no longer loses an evicted cursor, and one (#446)
    deleted row no longer takes a whole selection with it.

    The keyboard. The cursor survives its row being released — but
    moveIndexedFocus reconciled two-argument, so it could not tell an evicted row
    from a deleted one and dropped the cursor on the very next keystroke. That is
    precisely the state a user is in: they scrolled away from the cell, then pressed
    a key. The eviction context is now threaded through the move and through the
    store's moveFocus.

    A row-axis move from a cursor whose row is not loaded is refused — the
    cursor holds where the user left it — while the column axis still answers,
    because it never needed the row. The alternatives were both worse: jumping to
    the nearest loaded row teleports the cursor across however many rows were
    released, and moving to the adjacent dataset position cannot be expressed at
    all, because a focus ref addresses a cursor by row identity and the engine
    cannot name a row it has never loaded. A positional cursor that requests its own
    row is a real feature and a product decision; it is deliberately not smuggled in
    here.

    The selection. Retention was per range: reconciliation dropped a range as
    soon as either endpoint was proven deleted, and collapsed it onto the survivor
    when one endpoint was still loaded. So a range whose start was genuinely deleted
    while its end was merely evicted was discarded whole — an 81-row selection
    reporting 0, or 1, with 80 of those rows still loaded and painted.

    The spec states the rule per row, and datasetRowSpan is what makes that
    expressible: the proven-deleted rows prune and the span around them narrows.

    The range's endpoints Before Now
    one deleted, one evicted dropped whole narrows past the deleted row
    one deleted, one loaded collapsed onto the survivor narrows past the deleted row
    both evicted, neither provable kept whole unchanged
    every row in the span proven deleted dropped unchanged

    A deletion shifts everything after it down one, so whichever end was removed the
    survivors land on lo … hi - 1. The deleted endpoint's identity is replaced
    by the row that now holds the narrowed boundary, so no deleted row id lingers in
    a live selection — where the anchor reassignment would hand it straight back.
    Fail-closed throughout: a range with no readable span, an emptied span, or a
    narrowed boundary that is not loaded is still dropped exactly as before.

    Local mode — a grid with no window — is byte-for-byte unchanged in every branch.

Patch Changes

  • Data-honesty checks now read every input from one commit, and the engine-sort (#435)
    rule finally runs.

    A narrowing query no longer accuses you of a broken total. rows and
    resultMeta.total arrive together, but the row model ingests rows in a layout
    effect — after the render that already read the new total. The contiguous-window
    check therefore compared a new total against the previous query's row count:
    filter 480 rows down to 120 and it reported that 120 records "cannot be a
    contiguous window", then settled at the right aria-rowcount a render later.
    Because these warnings fire once per page load, that spurious first one
    permanently disarmed the check for the rest of the session — the real defect. In
    rows mode the loaded count now comes from the rows the consumer just handed
    over, and the "no total supplied" fallback counts the same records; explicit-model
    mode still reads the model, which has no such skew and whose rows prop is an
    empty array rather than an absent one.

    processing: { filter: "external", sort: "engine" } over a partial window now
    warns.
    The rule was written, unit-tested, and never called from a render.
    Sorting a server-selected window locally presents the wrong sample under a
    truthful-looking aria-sort, and it fires only where that is provable: an exact
    resultMeta.total counting more records than the grid holds. Wiring it depended
    on the fix above — the same one-render skew made an ordinary widening query look
    like a partial window.

    Settled behaviour is unchanged: the same counts, the same scope answers, and the
    same warning for a resultMeta.total that really is inconsistent with the rows.

  • processing: { filter: "external" } now stops the engine applying (#447)
    query.filters, without changing anything the grid reports.

    Declaring external filter authority used to change nothing about which rows were
    drawn: the engine went on re-applying the published filters to whatever rows it
    was handed. That is idempotent while the rows and the query agree — the server
    answered the same query, so re-selecting changed nothing — which is why it went
    unnoticed. It stops being idempotent the moment they disagree, and the lifecycle
    guarantees they will: during dataState.phase === "stale" the loaded rows answer
    the PREVIOUS query while the grid already holds the NEW one, so the engine
    filtered the old window by the new filter and the reader watched rows vanish and
    return. The same mechanism was reproducible in the error phase — same rows,
    same failed request, contains "fail" emptying the body while notContains "fail" kept every row.

    Under external authority the consumer owns which records exist, so re-applying
    the query was the grid overruling the authority it had just been told it does not
    have.

    Nothing that is REPORTED moves. The funnel still shows the active filter,
    onQueryChange still publishes it, the query in the snapshot is byte-for-byte
    what it was, and aria-sort is untouched. A filter naming a column that does not
    exist is still rejected. Only the record selection stops.

    Two consequences worth naming:

    • Group aggregates fold everything loaded. With aggregateFilteredRows, the
      filtered population under suppression is the whole loaded window, because the
      server already chose it. Groups whose rows all failed the local filter now
      appear with their totals instead of disappearing.
    • Sort is deliberately unchanged. sort: "external" still lets the engine
      order the rows it was given; only filtering is suppressed.

    Rows mode only. A consumer who supplies their own model through model= already
    decides what goes into the query and can omit the filters themselves, so the
    surface never moves that model's authority.

  • Stop segmenting graphemes for text that cannot need it. Grapheme-accurate (#428)
    counting made prepareText — which runs per wrapped cell on the row-height
    estimate path — 97% segmentation by cost, charged twice per string: once for
    the whole text and again per token. ASCII cannot form a multi-code-unit
    grapheme cluster, so such text now counts by code-unit length and segments by
    character; CRLF, the sole exception, still takes the segmenter, as does any
    text carrying a character outside ASCII. On the S2 hypothesis scroll
    benchmark this returns scroll_frame_p95_ms from 31.8/32.4 to 17.2/18.0 —
    four 120Hz ticks per scroll step back to two — with no predicted line count
    and no estimate changed.

  • Stop recomputing the estimator's line count on every measurement. The row (#432)
    layout controller asks predictRowLineCount to classify each measured data row
    for the height calibration, and the estimator had already computed that number
    for the same row from the same inputs — so the calibration path re-prepared and
    re-laid out every wrapped cell in the grid once per commit. The count is now
    stored on the estimate's existing cache entry and read back. Measured on the S2
    hypothesis scroll benchmark, time under predictRowLineCount falls from
    17.3ms (3.27% of the run) to 1.1ms (0.22%). No estimate, line count or rendered
    row count changes.

  • The server-controlled data surface is no longer marked experimental. External (#426)
    filter/sort authority (PretableProcessingAuthority, PretableProcessingOptions),
    the dataState lifecycle (PretableDataState, PretableBodyStateKind) and
    result metadata (PretableMatchingTotal, PretableResultMeta) shipped across
    five releases, are locked behind the API-surface gate and carry e2e coverage, so
    the @experimental hedge on their TSDoc has been dropped. Their types and
    behavior are unchanged.

  • telemetry.windowGap now judges the viewport against geometry from a single (#445)
    commit, which fixes a false negative and a false positive that had the same
    cause.

    The boundary is the plan's, not arithmetic over it. The end of the loaded
    window was reconstructed as totalHeight - trailingRows * rowHeight: a pixel
    total published by the last row layout plan, minus a row count derived fresh
    from resultMeta every render. The row layout controller does not replan on a
    resultMeta-only change, so those two halves could describe different states of
    the world. A total that GREW pushed the boundary further away and hid the
    defect; a total that SHRANK moved it the other way, and windowGap went
    silently absent for a viewport still genuinely past the loaded rows — until any
    scroll or row change triggered a replan. The plan already publishes both honest
    halves of that boundary (leadingHeight and the loaded rows' own height), so
    the judgement now reads them directly and reconstructs nothing. When the plan is
    current the two expressions are the same number by construction, so no answer
    that was already correct has changed.

    No gap is reported while the layout does not describe the rows. At mount
    there is no geometry — the boundary sits at pixel 0 — so every viewport, on a
    grid nobody had scrolled, read as past its own window. That covers three states
    that all publish totalHeight: 0: before the first plan, a first block of rows
    landing against the plan for an empty grid, and the render where rows have
    been handed over but the row model has not ingested them yet. A gap is now
    reported only once the plan, the row model and the rows you supplied agree on
    how many records are loaded. On the windowing docs example, mount went from four
    requests to one — the example's own.

    Absence of a gap has always meant "no signal" rather than "the viewport is
    inside the window", and that is unchanged: a handler that can tell from its own
    state that the reader is past the loaded block should still act on what it
    knows.

  • Updated dependencies [762bcb0, 076a36f, 305f8f4, f37fa1c]:

    • @pretable/core@0.11.0
    • @pretable/ui@0.11.0

@pretable/stream-adapter@0.11.0

@pretable/ui@0.11.0

@pretable/app-bench@0.0.33

Patch Changes

@pretable/app-website@0.0.33

Patch Changes

@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pretable Ignored Ignored Aug 16, 2026 3:28am

Request Review

@blove
blove enabled auto-merge (squash) August 15, 2026 03:51
@github-actions

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-61ui2deb7-cacheplane.vercel.app
Commit: 3f7748238ff30e7e6f7317c24a90ad537646b357

Updated automatically by the deploy-preview job.

@github-actions
github-actions Bot force-pushed the changeset-release/main branch 14 times, most recently from 141ef19 to dccb86e Compare August 16, 2026 02:48
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from dccb86e to fd5cc1f Compare August 16, 2026 03:28
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.

1 participant