From 80b203a00e3d64d179d98023ec980291530f79b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:26:59 +0000 Subject: [PATCH 1/2] fix(app-shell): the console-made tab of a view-less object keeps toolbar changes for the session (objectui#11643) On an object that declares no list view, buildViewTabs pushes a tab the console makes itself (id `all`). Its toolbar changes took the overlay branch of persistViewPatch and were sent as PUT /meta/view/all with no viewKind; the door refused them with 422 INVALID_METADATA and the change was gone on reload. The tab is now marked where it is made, with a module-local symbol, and persistViewPatch returns before scheduling anything for a marked tab that no stored row backs. One rule covers density, sort, hidden fields, column state and inline edit. A served view whose id is also `all`, a saved view beside the tab, and a stored row named `all` that shadows it keep saving. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/11643-fallback-tab-session-only.md | 11 + ...View.fallbackTabSessionOnly-11643.test.tsx | 400 ++++++++++++++++++ packages/app-shell/src/views/ObjectView.tsx | 51 +++ 3 files changed, 462 insertions(+) create mode 100644 .changeset/11643-fallback-tab-session-only.md create mode 100644 packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx diff --git a/.changeset/11643-fallback-tab-session-only.md b/.changeset/11643-fallback-tab-session-only.md new file mode 100644 index 0000000000..846b10f813 --- /dev/null +++ b/.changeset/11643-fallback-tab-session-only.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +--- + +On an object that declares no list view, a grid toolbar change no longer sends a save the door refuses (objectui#11643). Such an object opens on the "All Records" tab the console makes for it, whose id is `all`. A density change there used to send `PUT /api/v1/meta/view/all` with no `viewKind`, the door answered `422 INVALID_METADATA`, an error toast appeared, and the density was gone on reload. + +That tab is not a stored or served view, so there is no row to save into. Its toolbar changes (density, sort, hidden fields, column order and widths, inline edit) now apply for the session only: the list keeps them, no request is sent and no error is shown. No view is created to hold them; an object that wants saved toolbar settings declares a list view, and the served-view save path applies to it. + +The console tells its own tab apart by where it was made, not by its id. A served view whose tab id is also `all`, a saved view beside the console's tab, and a stored row named `all` that shadows that tab all keep saving as before. + +**Clause-②: no.** Nothing on the package entry changes. diff --git a/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx b/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx new file mode 100644 index 0000000000..6bdb87556b --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx @@ -0,0 +1,400 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11643 — on an object that declares no list view, the tab the console + * makes for it ("All Records", id `all`) keeps its toolbar changes for the + * session and sends no metadata write. + * + * ## The defect this pins + * + * `buildViewTabs` pushes a console-made fallback tab when an object declares no + * defined or primary view. Its toolbar changes went down `persistViewPatch`'s + * overlay branch like a served view's: measured live on objectstack `main` + * (showcase, `showcase_account`), a density toggle sent `PUT + * /api/v1/meta/view/all` with `{ rowHeight, object, name: 'all', _isOverride }` + * and no `viewKind`, the door answered `422 INVALID_METADATA`, and the density + * was gone on reload. + * + * Triage's ruling (comment `5988346142`): the fallback tab's toolbar changes + * apply for the session and send no `PUT /meta/view/…`; no view is created to + * hold them. On an object with a served view the save path is unchanged. + * + * ## What runs + * + * The harness of `ObjectView.toolbarWritesCompose-11642.test.tsx`: the real + * object page and the real `persistViewPatch` (its 300 ms debounce included), + * over the real `ObjectStackAdapter`, whose metadata client is a store that + * judges every PUT with the spec's own `ViewMetadataSchema` and keeps the parsed + * value. `ListView` is stubbed to capture what the page hands it, so a toolbar + * change is the page's own callback — every one that reaches `persistViewPatch`. + * A "reload" unmounts the page and mounts it again over a fresh adapter on the + * same store. + * + * ## Direction, written before the reverse-verification run + * + * - Guard removed: the fallback-tab case is PREDICTED red (the writes are sent, + * the store refuses the body that carries no `viewKind`, a toast is raised); + * the three save-path cases stay green. + * - Guard keyed on the id `all` instead of the mark: the served view named `all` + * and the stored row named `all` are PREDICTED red (their writes stop); the + * fallback-tab case stays green. + * - The stored-row clause dropped: only the stored row named `all` is PREDICTED + * red. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { normalizeListViewSchema } from '@object-ui/core'; +import { ViewMetadataSchema } from '@objectstack/spec/ui'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities: `ListView` names `perms` in its fetch dependencies. + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +/** What the object page hands `ListView`: captured, not rendered. */ +let listProps: any = null; +let listSchema: any = null; +vi.mock('@object-ui/plugin-list', async (importOriginal) => ({ + ...(await importOriginal()), + ListView: (props: any) => { + listProps = props; + listSchema = props.schema; + return null; + }, +})); + +import { toast } from 'sonner'; +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'track_note'; +const SAVED_ID = `${OBJECT_NAME}.mine`; + +const FIELDS = { + id: { type: 'text', label: 'Id' }, + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, +}; + +/** An object that declares no list view: the page makes the "All Records" tab for it. */ +const VIEWLESS_OBJECT = { name: OBJECT_NAME, label: 'Note', fields: FIELDS }; + +/** A user-saved view on that object — a real row, beside the console-made tab. */ +const SAVED_ROW = { + name: SAVED_ID, + object: OBJECT_NAME, + viewKind: 'list', + label: 'Mine', + config: { + type: 'grid', + data: { provider: 'object', object: OBJECT_NAME }, + columns: ['title', 'status'], + filter: [{ field: 'status', operator: 'equals', value: 'mine' }], + }, +}; + +/** + * A stored row whose name is `all` and that carries no overlay marker, so the + * switcher lists it as a saved view and it shadows the console-made tab. + */ +const STORED_ALL_ROW = { + name: 'all', + object: OBJECT_NAME, + viewKind: 'list', + label: 'All (stored)', + type: 'grid', + columns: ['title', 'status'], +}; + +/** An object whose OWN `listViews` declares a served view under the bare key `all`. */ +const SERVED_ALL_OBJECT = { + ...VIEWLESS_OBJECT, + listViews: { all: { label: 'All notes', type: 'grid', columns: ['title', 'status'] } }, +}; + +/** A marked overlay on that served view, from an earlier session. */ +const SERVED_ALL_OVERLAY = { + name: 'all', + object: OBJECT_NAME, + viewKind: 'list', + columnState: { widths: { title: 240 } }, + _isOverride: true, +}; + +const SORT = [{ id: 'row-1', field: 'title', order: 'asc' }]; + +/** The console-made tab's label: no locale bundle is loaded here, so the key renders. */ +const FALLBACK_TAB_LABEL = 'console.objectView.allRecords'; + +/** + * A `sys_metadata`-shaped store: every PUT is judged by the spec's + * `ViewMetadataSchema`, the PARSED value is kept, and the PUT is answered the + * way the door answers it — with no row. + */ +function makeStore(seed: Record[]) { + const rows = new Map(); + for (const row of seed) rows.set(row.name, structuredClone(row)); + let seq = 0; + const bodies: any[] = []; + const meta = { + getItems: vi.fn(async (type: string) => ({ + type, + items: type === 'view' ? [...rows.values()].map((r) => structuredClone(r)) : [], + })), + getItem: vi.fn(async (type: string, name: string) => { + const item = type === 'view' ? rows.get(name) : undefined; + if (!item) throw Object.assign(new Error(`Not found: ${type}/${name}`), { status: 404 }); + return { type, name, item: structuredClone(item) }; + }), + saveItem: vi.fn(async (_type: string, name: string, item: any) => { + bodies.push(structuredClone(item)); + const judged = ViewMetadataSchema.safeParse(item); + if (!judged.success) { + throw Object.assign(new Error(`422 INVALID_METADATA: ${judged.error.message}`), { status: 422 }); + } + const kept = Object.fromEntries( + Object.entries(judged.data as Record).filter(([key]) => key in item), + ); + rows.set(name, kept); + seq += 1; + return { success: true, version: `hmac-sha256:${'0'.repeat(63)}${seq}`, seq, state: 'active', message: `Saved view '${name}'` }; + }), + }; + return { meta, rows, bodies }; +} + +/** The real adapter over the store, and the page's data source over the adapter. */ +function pageDataSource(meta: any) { + const ds: any = new ObjectStackAdapter({ + baseUrl: 'http://test.local', + fetch: vi.fn(async () => + new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })), + }); + ds.connected = true; + ds.connectionState = 'connected'; + ds.client = { meta }; + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + listViews: (objectName: string, o?: any) => ds.listViews(objectName, o), + listViewOverrides: (objectName: string) => ds.listViewOverrides(objectName), + getView: (objectName: string, viewId: string) => ds.getView(objectName, viewId), + updateViewConfig: vi.fn((objectName: string, viewId: string, config: any, o?: any) => + ds.updateViewConfig(objectName, viewId, config, o)), + } as any; +} + +const wait = (ms: number) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +/** Mount the object page (on `viewId`, or on the object's default tab) and wait for its list schema. */ +async function openPage(meta: any, object: Record, viewId?: string) { + listProps = null; + listSchema = null; + const dataSource = pageDataSource(meta); + const page = ( + {}} /> + ); + render( + + + + + + + + , + ); + await waitFor(() => expect(typeof listSchema?.onDensityChange).toBe('function')); + // Let the page's own reads (saved views, stored rows) land before a toggle. + await wait(50); + return dataSource; +} + +/** Unmount, then open the view again over a fresh adapter: what a reload renders. */ +async function reload(meta: any, object: Record, viewId: string) { + cleanup(); + await openPage(meta, object, viewId); + await waitFor(() => expect(listSchema?.options).toBeTruthy()); + return (normalizeListViewSchema(listSchema) as { rowHeight?: unknown }).rowHeight; +} + +/** The refusal `persistViewPatch` logs before it toasts. */ +function persistFailuresLogged(): unknown[][] { + return (console.error as unknown as { mock: { calls: unknown[][] } }).mock.calls + .filter((call) => String(call[0]).includes('Failed to persist view config')); +} + +beforeEach(() => { + cleanup(); + listProps = null; + listSchema = null; + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +describe('objectui#11643 — the console-made tab of a view-less object keeps its toolbar changes for the session', () => { + it('every toolbar control on it sends no metadata write and raises no error', async () => { + const { meta, rows } = makeStore([]); + const ds = await openPage(meta, VIEWLESS_OBJECT); + // The page opened the tab it made: the object declares nothing else. + expect(listSchema.label).toBe(FALLBACK_TAB_LABEL); + + // Every control that reaches `persistViewPatch`, through both the schema's + // and the list's own spelling of it. + act(() => { + listSchema.onDensityChange('comfortable'); + listSchema.onSortChange(SORT); + listSchema.onHiddenFieldsChange(['status']); + listSchema.onColumnStateChange({ widths: { title: 240 } }); + listProps.onSortChange(SORT); + listProps.onHiddenFieldsChange(['status']); + listProps.onInlineEditChange(true); + listProps.onColumnStateChange({ order: ['status', 'title'] }); + }); + // Past the 300 ms debounce and any round trip it would have started. + await wait(700); + + expect(ds.updateViewConfig).not.toHaveBeenCalled(); + expect(meta.saveItem).not.toHaveBeenCalled(); + expect(meta.getItem).not.toHaveBeenCalled(); + expect(rows.size).toBe(0); + expect(toast.error).not.toHaveBeenCalled(); + expect(persistFailuresLogged()).toEqual([]); + }); + + it('a saved view beside it is a real row: its density change is still written and survives a reload', async () => { + const { meta, rows } = makeStore([SAVED_ROW]); + + // The console-made tab sits beside the saved view and still sends nothing. + const onFallback = await openPage(meta, VIEWLESS_OBJECT, 'all'); + expect(listSchema.label).toBe(FALLBACK_TAB_LABEL); + act(() => listSchema.onDensityChange('comfortable')); + await wait(700); + expect(onFallback.updateViewConfig).not.toHaveBeenCalled(); + expect(meta.saveItem).not.toHaveBeenCalled(); + + cleanup(); + const onSaved = await openPage(meta, VIEWLESS_OBJECT, SAVED_ID); + expect(listSchema.label).toBe('Mine'); + act(() => listSchema.onDensityChange('comfortable')); + await wait(700); + + expect(onSaved.updateViewConfig).toHaveBeenCalledTimes(1); + expect(onSaved.updateViewConfig.mock.calls[0][1]).toBe(SAVED_ID); + expect(onSaved.updateViewConfig.mock.calls[0][3]).toEqual({ isSavedView: true }); + expect(toast.error).not.toHaveBeenCalled(); + const stored = rows.get(SAVED_ID); + expect(stored.config.rowHeight).toBe('medium'); + expect(stored.config.filter).toEqual(SAVED_ROW.config.filter); + expect(stored).not.toHaveProperty('_isOverride'); + + expect(await reload(meta, VIEWLESS_OBJECT, SAVED_ID)).toBe('medium'); + }); +}); + +describe('objectui#11643 — provenance decides, not the id `all`', () => { + it('a served view whose tab id is `all` keeps its overlay save path', async () => { + const { meta, rows } = makeStore([SERVED_ALL_OVERLAY]); + const ds = await openPage(meta, SERVED_ALL_OBJECT, 'all'); + expect(listSchema.label).toBe('All notes'); + act(() => listSchema.onDensityChange('comfortable')); + await wait(700); + + expect(ds.updateViewConfig).toHaveBeenCalledTimes(1); + expect(ds.updateViewConfig.mock.calls[0][1]).toBe('all'); + expect(ds.updateViewConfig.mock.calls[0][3]).toEqual({ isSavedView: false }); + expect(toast.error).not.toHaveBeenCalled(); + const stored = rows.get('all'); + expect(stored.rowHeight).toBe('medium'); + expect(stored.columnState).toEqual(SERVED_ALL_OVERLAY.columnState); + expect(stored._isOverride).toBe(true); + + expect(await reload(meta, SERVED_ALL_OBJECT, 'all')).toBe('medium'); + }); + + it('a stored row named `all` that shadows the console-made tab is a real row and keeps its save path', async () => { + const { meta, rows } = makeStore([STORED_ALL_ROW]); + const ds = await openPage(meta, VIEWLESS_OBJECT, 'all'); + // The stored row's own label: the switcher merged it onto the tab the page made. + expect(listSchema.label).toBe('All (stored)'); + act(() => listSchema.onDensityChange('comfortable')); + await wait(700); + + expect(ds.updateViewConfig).toHaveBeenCalledTimes(1); + expect(ds.updateViewConfig.mock.calls[0][3]).toEqual({ isSavedView: true }); + expect(toast.error).not.toHaveBeenCalled(); + const stored = rows.get('all'); + expect(stored.rowHeight).toBe('medium'); + expect(stored.columns).toEqual(STORED_ALL_ROW.columns); + expect(stored).not.toHaveProperty('_isOverride'); + + expect(await reload(meta, VIEWLESS_OBJECT, 'all')).toBe('medium'); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 7d56c0a422..42a9655632 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -1296,6 +1296,44 @@ async function toolbarWriteInputs( return { base: activeTab, patch: { ...owned, ...pending } }; } +/** + * The provenance mark on the tab this page MAKES for an object that declares no + * list view (objectui#11643): the `fallbackTab` the views memo hands + * {@link buildViewTabs}, which pushes it only when no defined or primary view + * exists. + * + * That tab is not a view anybody authored or served. No server serves it, and + * unless a stored row of the same name shadows it, no row backs it. Its id + * `all` is the console's placeholder, not a view name: a bare `all` would + * collide across objects, and storing a toolbar change under it would mint a + * metadata row nobody authored. Before this mark, a toolbar change on it took + * the overlay branch of `persistViewPatch` like a served view's and was sent as + * `PUT /meta/view/all` carrying no `viewKind`; with no registry entry of that + * name to inherit one from, the door judged the body as a view container and + * refused it (`422 INVALID_METADATA`), and the change was gone on reload. + * + * Triage's ruling (objectui#11643, comment `5988346142`): the fallback tab's + * toolbar changes apply for the session and send no metadata write, and no view + * is created to hold them. An object that wants saved personalization declares + * a list view, and the served-view path then applies. + * + * Why a mark and not the id: an object may declare a served list view whose tab + * id is `all` as well (a bare key in the object's own `listViews`), and that one + * keeps saving. Provenance is decided where the tab is made. Object spread and + * `Object.assign` copy a symbol-keyed property, so the mark rides every copy of + * the tab this page takes (the views memo, the active view, a config draft + * spread over it); `Object.entries`, `Object.keys` and `JSON.stringify` skip + * it, so it never reaches a request body. Module-local and not exported: it is + * a fact about this page's own tab, not a contract. + */ +const CONSOLE_MADE_TAB = Symbol('objectui#11643 console-made fallback tab'); + +/** Whether `tab` is the tab this page made for a view-less object — see {@link CONSOLE_MADE_TAB}. */ +function isConsoleMadeTab(tab: unknown): boolean { + return !!tab && typeof tab === 'object' + && (tab as { [CONSOLE_MADE_TAB]?: unknown })[CONSOLE_MADE_TAB] === true; +} + /** * Item-level keys a switcher tab carries that belong to the ROW, not to the * view body — the ones this surface's own handlers write through `updateView` @@ -1544,6 +1582,16 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co const persistViewPatch = useCallback( (viewIdLocal: string, baseViewDef: Record, patch: Record) => { if (!dataSource?.updateViewConfig || !objectName || !viewIdLocal) return; + // objectui#11643 — the tab this page made for an object that + // declares no list view has no row to write into. Its toolbar + // changes apply for the session (the list keeps them in its own + // state) and nothing is scheduled here: no read, no PUT, no toast. + // One rule for every control that reaches this function — density, + // sort, hidden fields, column order and widths, inline edit. A + // stored row that shadows the tab is a real row (`isSavedViewId`, + // the classification the write below uses) and keeps its save + // path. See `CONSOLE_MADE_TAB`. + if (isConsoleMadeTab(baseViewDef) && !isSavedViewId(savedViewsRef.current, viewIdLocal)) return; // Merge into pending payload — every key present is the latest // value the user intended. const prev = persistPending.current[viewIdLocal] || {}; @@ -2195,6 +2243,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co id: 'all', label: t('console.objectView.allRecords'), type: 'grid', + // objectui#11643 — made here, so marked here: its toolbar + // changes are session-only. See `CONSOLE_MADE_TAB`. + [CONSOLE_MADE_TAB]: true, }), }); From 04a70d57d94e39824532895a6662ec5e78278df0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:46:55 +0000 Subject: [PATCH 2/2] test(app-shell): record the measured direction of the objectui#11643 reverse-verification run Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- ...tView.fallbackTabSessionOnly-11643.test.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx b/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx index 6bdb87556b..bb28faec73 100644 --- a/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx +++ b/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx @@ -36,16 +36,18 @@ * A "reload" unmounts the page and mounts it again over a fresh adapter on the * same store. * - * ## Direction, written before the reverse-verification run + * ## Direction of the reverse-verification run * - * - Guard removed: the fallback-tab case is PREDICTED red (the writes are sent, - * the store refuses the body that carries no `viewKind`, a toast is raised); - * the three save-path cases stay green. + * - Guard removed: every case that drives the console-made tab goes red on its + * write count — the first test, and the first half of the saved-view test, + * which toggles that tab before it opens the saved view. The prediction named + * the first test only; the run showed both. The two cases keyed on `all` + * stay green. * - Guard keyed on the id `all` instead of the mark: the served view named `all` - * and the stored row named `all` are PREDICTED red (their writes stop); the - * fallback-tab case stays green. - * - The stored-row clause dropped: only the stored row named `all` is PREDICTED - * red. + * and the stored row named `all` go red (their writes stop); the two + * console-made-tab cases stay green. As predicted. + * - The stored-row clause dropped: only the stored row named `all` goes red. As + * predicted. */ import * as React from 'react';