Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
97381ef
docs: Add v0.16 migration codemod
ntucker Mar 29, 2026
e808683
fix: bugbot
ntucker Mar 29, 2026
8d10823
Fix schema codemod: aliased import skipped when name already exists i…
cursoragent Mar 29, 2026
42be09c
fix: scope useFetch truthiness rewrites to enclosing function
cursoragent Mar 29, 2026
faf1adc
fix(codemod): use optional chaining in useFetch truthiness rewrite
cursoragent Mar 29, 2026
019edc8
fix(codemod): rewrite useFetch vars on both sides of LogicalExpression
cursoragent Mar 29, 2026
ca2a692
fix(codemod): preserve schema import when bare identifier references …
cursoragent Mar 29, 2026
c7d3c25
fix: bugbot
ntucker Mar 30, 2026
460f3da
fix(codemod): skip schema.Object and schema.Array in v0.16 transform
cursoragent Mar 30, 2026
a7677f3
Fix transformUseFetch to only apply when useFetch is imported from @d…
cursoragent Mar 30, 2026
255f8f2
fix(codemod): skip schema.X rewrites where schema is shadowed by loca…
cursoragent Mar 30, 2026
94727d0
fix(codemod): preserve boolean semantics for useFetch conditional fet…
cursoragent Mar 30, 2026
b9c004e
fix(codemod): detect top-level var/function/class bindings in scopeBi…
cursoragent Mar 30, 2026
9b24df8
fix(codemod): detect FunctionDeclaration/ClassDeclaration shadowing i…
cursoragent Mar 30, 2026
e57ce92
Fix transformUseFetch rewriting shadowed variables in nested callbacks
cursoragent Mar 30, 2026
460c9de
Guard transformPaths with @data-client import check
cursoragent Mar 30, 2026
b73054e
Fix resolveLocal collision: verify 'Schema'+name is also free of conf…
cursoragent Mar 30, 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
6 changes: 6 additions & 0 deletions website/blog/2026-01-19-v0.16-release-announcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,12 @@ This upgrade requires updating all package versions simultaneously.

<PkgTabs pkgs="@data-client/react@^0.16.0 @data-client/rest@^0.16.0 @data-client/endpoint@^0.16.0 @data-client/core@^0.16.0 @data-client/vue@^0.16.0 @data-client/test@^0.16.0 @data-client/img@^0.16.0" upgrade />

An automated [codemod](/codemods/v0.16.js) handles all three breaking changes below — path syntax, `useFetch()` truthiness, and `schema` namespace imports:

```bash
npx jscodeshift -t https://dataclient.io/codemods/v0.16.js --extensions=ts,tsx,js,jsx src/
```

## path-to-regexp v8

[RestEndpoint.path](/rest/api/RestEndpoint#path) now uses [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp/releases/tag/v8.0.0),
Expand Down
258 changes: 258 additions & 0 deletions website/static/codemods/v0.16.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
'use strict';

/**
* @data-client v0.16 migration codemod
*
* Transforms:
* 1. path-to-regexp v6 → v8 syntax in RestEndpoint/resource path strings
* 2. useFetch() truthiness checks → .resolved checks
* 3. schema.X namespace → direct imports
*
* Usage:
* npx jscodeshift -t https://dataclient.io/codemods/v0.16.js --extensions=ts,tsx,js,jsx src/
*/

const DATA_CLIENT_PACKAGES = new Set([
'@data-client/endpoint',
'@data-client/rest',
]);

// --- path-to-regexp v6 → v8 ---

function transformPathString(s) {
// :name(\d+) → :name (handles nested non-capturing groups)
s = s.replace(/:(\w+)\((?:[^()]*|\([^()]*\))*\)/g, ':$1');
// {group}? → {group}
s = s.replace(/(\{[^}]+\})\?/g, '$1');
// /:name? → {/:name} (also handles - . ~ prefixes)
s = s.replace(/([/\-.~]):(\w+)\?/g, '{$1:$2}');
// /:name+ → /*name
s = s.replace(/\/:(\w+)\+/g, '/*$1');
// /:name* → {/*name}
s = s.replace(/\/:(\w+)\*/g, '{/*$1}');
// \? → ? and \+ → + (no longer special in v8)
s = s.replace(/\\([?+])/g, '$1');
return s;
}

function transformPaths(j, root) {
let dirty = false;

root.find(j.StringLiteral).forEach(path => {
const parent = path.parent.node;
if (
(parent.type === 'Property' || parent.type === 'ObjectProperty') &&
parent.value === path.node &&
parent.key &&
(parent.key.name === 'path' ||
(parent.key.type === 'StringLiteral' && parent.key.value === 'path'))
) {
const updated = transformPathString(path.node.value);
if (updated !== path.node.value) {
path.node.value = updated;
if (path.node.extra) {
delete path.node.extra.raw;
delete path.node.extra.rawValue;
}
dirty = true;
}
}
});

return dirty;
}
Comment thread
cursor[bot] marked this conversation as resolved.

// --- useFetch() truthiness → .resolved ---

function transformUseFetch(j, root) {
let dirty = false;
const vars = new Set();

root.find(j.VariableDeclarator).forEach(p => {
const init = p.node.init;
if (
init &&
init.type === 'CallExpression' &&
init.callee &&
init.callee.type === 'Identifier' &&
init.callee.name === 'useFetch' &&
p.node.id.type === 'Identifier'
) {
vars.add(p.node.id.name);
}
});

if (!vars.size) return dirty;

function rewrite(test) {
// promise → !promise.resolved
if (test.type === 'Identifier' && vars.has(test.name)) {
return j.unaryExpression(
'!',
j.memberExpression(j.identifier(test.name), j.identifier('resolved')),
);
Comment thread
cursor[bot] marked this conversation as resolved.
}
// !promise → promise.resolved
if (
test.type === 'UnaryExpression' &&
test.operator === '!' &&
test.argument.type === 'Identifier' &&
vars.has(test.argument.name)
) {
return j.memberExpression(
j.identifier(test.argument.name),
j.identifier('resolved'),
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
return null;
}

[j.IfStatement, j.ConditionalExpression].forEach(type => {
root.find(type).forEach(p => {
const r = rewrite(p.node.test);
if (r) {
p.node.test = r;
dirty = true;
}
});
});

root.find(j.LogicalExpression, { operator: '&&' }).forEach(p => {
const r = rewrite(p.node.left);
if (r) {
p.node.left = r;
dirty = true;
}
});
Comment thread
cursor[bot] marked this conversation as resolved.

Comment thread
cursor[bot] marked this conversation as resolved.
return dirty;
}

// --- schema.X namespace → direct imports ---

const JS_GLOBALS = new Set([
'Array',
'Boolean',
'Date',
'Error',
'Function',
'JSON',
'Map',
'Math',
'Number',
'Object',
'Promise',
'Proxy',
'Reflect',
'RegExp',
'Set',
'String',
'Symbol',
'WeakMap',
'WeakSet',
]);

function transformSchemaImports(j, root) {
let dirty = false;

root.find(j.ImportDeclaration).forEach(importPath => {
const source = importPath.node.source.value;
if (!DATA_CLIENT_PACKAGES.has(source)) return;

const specs = importPath.node.specifiers;
const idx = specs.findIndex(
s =>
s.type === 'ImportSpecifier' &&
s.imported &&
(s.imported.name === 'schema' || s.imported.value === 'schema'),
);
if (idx === -1) return;

const local = specs[idx].local.name;
// Map from exported name → local identifier to use in code
const used = new Map();

const scopeBindings = new Set();
root.find(j.ImportDeclaration).forEach(ip => {
if (ip === importPath) return;
ip.node.specifiers.forEach(s => {
if (s.local) scopeBindings.add(s.local.name);
});
});
Comment thread
ntucker marked this conversation as resolved.

function resolveLocal(name) {
if (JS_GLOBALS.has(name) || scopeBindings.has(name)) {
return 'Schema' + name;
}
return name;
}
Comment thread
cursor[bot] marked this conversation as resolved.

root
.find(j.MemberExpression, {
object: { type: 'Identifier', name: local },
})
.forEach(mp => {
if (mp.node.property.type === 'Identifier') {
const name = mp.node.property.name;
if (!used.has(name)) used.set(name, resolveLocal(name));
j(mp).replaceWith(j.identifier(used.get(name)));
}
});
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

// TypeScript qualified names: schema.Union in type positions
try {
root
.find(j.TSQualifiedName, {
left: { type: 'Identifier', name: local },
})
.forEach(qp => {
if (qp.node.right.type === 'Identifier') {
const name = qp.node.right.name;
if (!used.has(name)) used.set(name, resolveLocal(name));
j(qp).replaceWith(j.identifier(used.get(name)));
}
});
} catch (_) {}

if (!used.size) return;

specs.splice(idx, 1);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

const existingLocals = new Set(
specs.filter(s => s.type === 'ImportSpecifier').map(s => s.local.name),
);
for (const [imported, localName] of [...used.entries()].sort((a, b) =>
a[0].localeCompare(b[0]),
)) {
if (!existingLocals.has(localName)) {
if (imported !== localName) {
specs.push(
j.importSpecifier(j.identifier(imported), j.identifier(localName)),
);
} else {
specs.push(j.importSpecifier(j.identifier(imported)));
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

dirty = true;
});

return dirty;
}

// --- Main ---

module.exports = function transformer(fileInfo, api) {
const j = api.jscodeshift;
const root = j(fileInfo.source);
let dirty = false;

dirty = transformPaths(j, root) || dirty;
dirty = transformUseFetch(j, root) || dirty;
dirty = transformSchemaImports(j, root) || dirty;

return dirty ? root.toSource({ quote: 'single' }) : undefined;
};

module.exports.parser = 'tsx';