Skip to content

Commit a9c5ea0

Browse files
fix(fields): a file URL whose last segment holds a bare % renders, named by its raw segment (objectui#10614) (#10653)
Fixes #10614 Clause-②: no ## What changed `nameFromUrl` in `packages/fields/src/widgets/file-value.ts` names a file value that carries no name after its URL's last path segment, decoded with `decodeURIComponent`. A segment that is not a valid percent-encoding made the decode throw `URIError: URI malformed`, and the throw left `readFileValue` during render. The decode is now guarded once, in that helper. On `URIError` the name is the raw segment; any other error is rethrown. No caller changed, and no caller has its own try/catch (triage ruling on the card, comment `5833345168`). A valid escape decodes exactly as before. Files: `packages/fields/src/widgets/file-value.ts` (the guard and its doc comment), `packages/fields/src/widgets/file-value.test.ts` (helper pins), `packages/fields/src/widgets/file-value.bare-percent-10614.test.tsx` (rendered-face pins, new), `.changeset/10614-file-name-bare-percent.md` (patch, `@object-ui/fields`). ## Reach, measured first at the base `eca2760` A scratch harness (not committed) mounted every face that reaches the helper. Values: `https://cdn.example.com/100%.png` (string) and `{ url: 'https://cdn.example.com/a%zz.pdf' }` (object). | Face | At the base | After the fix | |---|---|---| | `file` cell (`FileCellRenderer`), string and array | throws `URIError: URI malformed` | link named `100%.png` / `a%zz.pdf` | | `image` cell (`ImageCellRenderer`), string and object | throws `URIError: URI malformed` | renders | | `signature` cell (via `getCellRenderer`) | throws `URIError: URI malformed` | renders | | `FileField` edit and readonly | throws `URIError: URI malformed` | renders, name shown | | `FileCell` | throws `URIError: URI malformed` | renders, name shown | | `ImageField` edit and readonly | throws `URIError: URI malformed` | renders | | `object-grid` through `SchemaRenderer`, one bad row plus one valid row | the `data-table` node's error boundary replaces the WHOLE table with its panel `Component "data-table" failed to render` / `URI malformed` / `Retry`. Neither row is visible. | both rows visible, no alert | | `object-gallery` through `SchemaRenderer`, one bad cover plus one valid | the gallery's boundary replaces the whole gallery with `Component "object-gallery" failed to render` / `URI malformed` / `Retry` | both cards visible, no alert | | control: `file` cell with `report%20q3.pdf` | `report q3.pdf` | `report q3.pdf` | So at the base, no face contained the throw. Under `SchemaRenderer`, the nearest error boundary caught it, and one such value hid the whole table or gallery, valid rows included. The measurement did not produce a blank page, because `SchemaRenderer` wraps every node in a boundary. The console's list route reaches the grid through `ListView`, which renders it through `SchemaRenderer`. `ObjectGallery`'s cover (`resolveCoverUrl` calls `readFileValues`) is a caller the card did not list. `git grep` found it, and it was measured above. ## Pins, and the ablation - `file-value.test.ts`, the describe block whose name ends `names the file by its raw segment (objectui#10614)`. It pins both reported values (no throw, name is the raw segment), the two together through `readFileValues`, and THE CONTROL: `report%20q3.pdf` still decodes to `report q3.pdf`, both as a string and as `{ url }`. - `file-value.bare-percent-10614.test.tsx` renders the `file` cell through `getCellRenderer('file')`. It checks both values as links named by the raw segment with the right `href`, a single non-array value, and the control decoding beside a bare-`%` value in the same cell. - Ablation, run on the committed fix `551e8ed` with `ablation-replace.mjs` (wrap mode, anchor `if (err instanceof URIError) return seg;` deleted, so the throw escapes again as it did at the base). It printed `anchor x1 -> x0` and blob `a2c261c8e7d4 -> e873660a8615`. The two pin files then read `Tests 6 failed | 39 passed (45)`, `command exited 1`, and every failure is `URIError: URI malformed`. The helper control stays green in that run, as a control should. Restore: `blob == HEAD (a2c261c) and git diff HEAD is empty`. Direction observed: red, as expected. ## Gates, at head `1980ada` (after merging `origin/main` `f99f9cd`) - `pnpm exec vitest run packages/fields/`: exit 0, `Test Files 209 passed | 1 skipped (210)`, `Tests 3399 passed | 7 skipped (3406)` - `pnpm turbo run type-check --filter @object-ui/fields --concurrency=2`: exit 0, `Tasks: 12 successful, 12 total`, and `@object-ui/fields:type-check: cache miss, executing`, so it ran rather than replaying a cache. `tsc -p tsconfig.test.json --listFiles` lists both pin files, so the test program covers them. - `pnpm exec eslint` on the three changed source and test files: exit 0, no problems - `pnpm check:control-bytes`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:new-line-citations` (0 new), `check:changeset-claims`, `check:pending-changeset-literals`, `check:unreferenced-sources`: exit 0 each - `node scripts/check-changeset-presence.mjs`, `check-changeset-no-major.mjs`, `check-changeset-overwrite.mjs`, `check-lint-coverage.mjs`: exit 0 each - The repo-wide `pnpm lint` and the full test shards belong to CI. ## Acceptance notes - The boundary granularity is an observation, not a filed card. `SchemaErrorBoundary` sits at the schema node, so any throw from one cell renderer blanks the whole `data-table` node, not the cell. This card removes the throw. It does not change where boundaries sit. - The fix names a file by its raw segment. It does not validate the URL. The `href` is the value's own URL, unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d17a97 commit a9c5ea0

4 files changed

Lines changed: 148 additions & 1 deletion

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@object-ui/fields': patch
3+
---
4+
5+
fix(fields): a file URL whose last segment holds a bare `%` renders, named by its raw segment (objectui#10614)
6+
7+
`readFileValue` names a file value that carries no name of its own after the last
8+
segment of its URL, decoded with `decodeURIComponent`. A segment that is not a
9+
valid percent-encoding (`https://cdn.example.com/100%.png`, or
10+
`{ url: 'https://cdn.example.com/a%zz.pdf' }`) made that decode throw
11+
`URIError: URI malformed`, and the throw left `readFileValue` during render.
12+
`FileValueSchema.url` is a plain string, so such a value passes the contract.
13+
14+
What a user saw: every face that reads the value (the `file`, `image` and
15+
`signature` cells, `FileField`, `FileCell`, `ImageField`, and a gallery's cover)
16+
threw while rendering. Under `SchemaRenderer` the nearest error boundary caught it,
17+
so one such value replaced a whole grid table, valid rows included, or a whole
18+
gallery with the "failed to render" panel.
19+
20+
The decode is now guarded once, in the one helper that performs it: on `URIError`
21+
the name is the raw segment (`100%.png`, `a%zz.pdf`). A valid escape decodes as
22+
before (`report%20q3.pdf` is named `report q3.pdf`), and a value that carries its
23+
own `name` or `original_name` is unaffected.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10614, through a rendered face: a file value whose URL's last
11+
* segment is not a valid percent-encoding renders, named by its raw segment.
12+
*
13+
* `nameFromUrl` (in `file-value.ts`) passed that segment to
14+
* `decodeURIComponent` unguarded, so `https://cdn.example.com/100%.png` and
15+
* `{ url: 'https://cdn.example.com/a%zz.pdf' }` threw `URIError: URI malformed`
16+
* out of `readFileValue`, during render. Measured at the base `eca2760`
17+
* before this fix (a historical reading, not re-derived here): every face
18+
* that reads such a value threw that error during render, and none of them
19+
* contained it. Mounted through `SchemaRenderer`, the nearest error boundary
20+
* caught it and replaced the WHOLE view with its "failed to render" panel:
21+
* an `object-grid` lost its table (the `data-table` node), valid rows
22+
* included, and an `object-gallery` lost every card.
23+
*
24+
* The helper-level pins, and the control that a valid escape still decodes,
25+
* sit in `file-value.test.ts`. This file renders the record-detail face, the
26+
* `file` cell renderer, resolved the way production call sites resolve it.
27+
*/
28+
import React from 'react';
29+
import { describe, it, expect, afterEach } from 'vitest';
30+
import { render, cleanup, within } from '@testing-library/react';
31+
import '@testing-library/jest-dom';
32+
import { I18nProvider } from '@object-ui/i18n';
33+
import type { FileFieldMetadata } from '@object-ui/types';
34+
35+
import { getCellRenderer } from '../index';
36+
37+
const BARE_PERCENT = 'https://cdn.example.com/100%.png';
38+
const INVALID_ESCAPE = { url: 'https://cdn.example.com/a%zz.pdf' };
39+
const VALID_ESCAPE = 'https://cdn.example.com/report%20q3.pdf';
40+
41+
const fileField: FileFieldMetadata = { name: 'attachment', label: 'Attachment', type: 'file', multiple: true };
42+
43+
function renderFileCell(value: unknown) {
44+
const CellRenderer = getCellRenderer('file');
45+
return render(
46+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
47+
<CellRenderer value={value} field={fileField} />
48+
</I18nProvider>,
49+
);
50+
}
51+
52+
afterEach(() => {
53+
cleanup();
54+
});
55+
56+
describe('objectui#10614: the file cell renders a URL whose last segment holds a bare `%`', () => {
57+
it('both reported values render as links named by their raw last segment', () => {
58+
const { container } = renderFileCell([BARE_PERCENT, INVALID_ESCAPE]);
59+
expect(within(container).getByRole('link', { name: '100%.png' })).toHaveAttribute('href', BARE_PERCENT);
60+
expect(within(container).getByRole('link', { name: 'a%zz.pdf' })).toHaveAttribute('href', INVALID_ESCAPE.url);
61+
});
62+
63+
it('a single (non-array) value with a bare `%` renders too', () => {
64+
const { container } = renderFileCell(BARE_PERCENT);
65+
expect(within(container).getByRole('link', { name: '100%.png' })).toHaveAttribute('href', BARE_PERCENT);
66+
});
67+
68+
it('THE CONTROL: a valid escape in the same cell still decodes', () => {
69+
const { container } = renderFileCell([VALID_ESCAPE, BARE_PERCENT]);
70+
expect(within(container).getByRole('link', { name: 'report q3.pdf' })).toHaveAttribute('href', VALID_ESCAPE);
71+
expect(within(container).getByRole('link', { name: '100%.png' })).toHaveAttribute('href', BARE_PERCENT);
72+
});
73+
});

‎packages/fields/src/widgets/file-value.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,41 @@ describe('readFileValue', () => {
145145
});
146146
});
147147

148+
/**
149+
* objectui#10614: a last segment that is not a valid percent-encoding made
150+
* `decodeURIComponent` throw `URIError: URI malformed`, and `readFileValue`
151+
* threw with it, during render. `FileValueSchema.url` is a plain string, so
152+
* both values below pass the contract. The name is now the raw segment.
153+
*/
154+
describe('a last segment holding a bare `%` names the file by its raw segment (objectui#10614)', () => {
155+
it('a URL string whose last segment holds a bare `%` does not throw', () => {
156+
const url = 'https://cdn.example.com/100%.png';
157+
expect(() => readFileValue(url)).not.toThrow();
158+
expect(readFileValue(url)).toEqual({ url, name: '100%.png', raw: url });
159+
});
160+
161+
it('a { url } object whose last segment holds an invalid escape does not throw', () => {
162+
const value = { url: 'https://cdn.example.com/a%zz.pdf' };
163+
expect(() => readFileValue(value)).not.toThrow();
164+
const view = readFileValue(value);
165+
expect(view.name).toBe('a%zz.pdf');
166+
expect(view.url).toBe(value.url);
167+
});
168+
169+
it('readFileValues reads both values in one field without throwing', () => {
170+
expect(
171+
readFileValues(['https://cdn.example.com/100%.png', { url: 'https://cdn.example.com/a%zz.pdf' }]).map(
172+
(v) => v.name,
173+
),
174+
).toEqual(['100%.png', 'a%zz.pdf']);
175+
});
176+
177+
it('THE CONTROL: a valid escape still decodes', () => {
178+
expect(readFileValue('https://cdn.example.com/report%20q3.pdf').name).toBe('report q3.pdf');
179+
expect(readFileValue({ url: 'https://cdn.example.com/report%20q3.pdf' }).name).toBe('report q3.pdf');
180+
});
181+
});
182+
148183
it('resolves a bare reference to the stable download URL', () => {
149184
const view = readFileValue('file_a', 'File');
150185

‎packages/fields/src/widgets/file-value.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,28 @@ export function fileUrlFromId(id: string): string {
117117
* signature is one (objectui#10493). It names nothing, so the caller's
118118
* fallback applies. The scheme is matched case-insensitively, as URL schemes
119119
* are.
120+
*
121+
* A segment that is not a valid percent-encoding (a bare `%`, as in
122+
* `100%.png`, or `%zz`) names the file by its raw segment (objectui#10614).
123+
* `FileValueSchema.url` is a plain string, so such a value is contract-valid,
124+
* and `decodeURIComponent` throws `URIError` on it. Before this guard that
125+
* throw escaped `readFileValue` during render, so one such value took down
126+
* every face that reads it: the nearest error boundary replaced the whole
127+
* table or gallery, valid rows included. The guard lives here, once, because
128+
* every caller reaches the decode through this helper; a valid escape
129+
* (`report%20q3.pdf`) still decodes as before.
120130
*/
121131
function nameFromUrl(url: string): string | undefined {
122132
if (/^data:/i.test(url)) return undefined;
123133
const path = url.split(/[?#]/)[0] ?? url;
124134
const seg = path.split('/').filter(Boolean).pop();
125-
return seg ? decodeURIComponent(seg) : url;
135+
if (!seg) return url;
136+
try {
137+
return decodeURIComponent(seg);
138+
} catch (err) {
139+
if (err instanceof URIError) return seg;
140+
throw err;
141+
}
126142
}
127143

128144
/**

0 commit comments

Comments
 (0)